fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis - Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt - optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht - Titel-Schalter mit fester Beschriftung + aria-pressed - Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog - Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck - Uebersetzungen und CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
@@ -73,11 +77,21 @@ interface BoundCall {
|
||||
|
||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
dashboardBackground: unknown;
|
||||
}
|
||||
|
||||
interface FakePrisma {
|
||||
dashboardImage: ModelMethods;
|
||||
user: ModelMethods;
|
||||
__rows: ImageRow[];
|
||||
__users: UserRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeBoundClient(
|
||||
tenantId: string,
|
||||
userId?: string,
|
||||
): { dashboardImage: ModelMethods; user: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
|
||||
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
|
||||
const user: ModelMethods = {
|
||||
updateMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
|
||||
data: { dashboardBackground: unknown };
|
||||
};
|
||||
const [key] = args.where.dashboardBackground.path;
|
||||
let count = 0;
|
||||
for (const u of users) {
|
||||
const bg = u.dashboardBackground as Record<string, unknown> | null;
|
||||
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
|
||||
continue;
|
||||
u.dashboardBackground = args.data.dashboardBackground;
|
||||
count++;
|
||||
}
|
||||
return { count };
|
||||
}),
|
||||
};
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
count: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string } };
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
return rows.filter(
|
||||
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
|
||||
).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
const created = makeRow({
|
||||
id: `new-${rows.length + 1}`,
|
||||
...args.data,
|
||||
createdAt: new Date('2026-02-02'),
|
||||
});
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
for (const method of Object.keys(methods)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
boundCallLog.push({ tenantId, userId, model, method });
|
||||
return methods[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
return {
|
||||
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
|
||||
user: wrapModel('user', user, tenantId, userId),
|
||||
};
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
user,
|
||||
__rows: rows,
|
||||
__users: users,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
return wrap(tenantId, userId);
|
||||
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||
for (const r of result) {
|
||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(Object.keys(r).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'mimeType',
|
||||
'originalName',
|
||||
'size',
|
||||
]);
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
select: Record<string, boolean>;
|
||||
};
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.originalName).toBe('irgendwas.txt');
|
||||
expect(result.size).toBe(PNG.length);
|
||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'mimeType',
|
||||
'originalName',
|
||||
'size',
|
||||
]);
|
||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
||||
await expect(
|
||||
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
||||
const foreign = Array.from({ length: 30 }, (_, i) =>
|
||||
makeRow({ id: `f${i}`, userId: 'user-2' }),
|
||||
);
|
||||
const prisma = makeFakePrisma(foreign);
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.__rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
|
||||
const users: UserRow[] = [
|
||||
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||
];
|
||||
const prisma = makeFakePrisma(
|
||||
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
|
||||
users,
|
||||
);
|
||||
const service = makeService(prisma);
|
||||
|
||||
await service.remove('eigen', 'user-1', 'tenant-1');
|
||||
|
||||
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
|
||||
// nur die eigene Zeile
|
||||
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
|
||||
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
||||
expect(call).toEqual({
|
||||
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
|
||||
data: { dashboardBackground: { kind: 'none' } },
|
||||
});
|
||||
|
||||
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
|
||||
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
|
||||
await service.remove('zweites', 'user-1', 'tenant-1');
|
||||
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
|
||||
});
|
||||
|
||||
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
|
||||
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
|
||||
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'eigen',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
|
||||
expect(methods).toEqual([
|
||||
'findMany',
|
||||
'count',
|
||||
'create',
|
||||
'findUnique',
|
||||
'findUnique',
|
||||
'delete',
|
||||
'updateMany',
|
||||
]);
|
||||
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
|
||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(prisma.__rows[0].storagePath).toBe(
|
||||
`user-files/dashboard-images/user-1/${result.id}.png`,
|
||||
);
|
||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||
// die Zeile.
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(createArgs.data.id).toBe(result.id);
|
||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(result.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(createArgs.data).not.toHaveProperty('data');
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||
makeRow({
|
||||
id: 'datei-fehlt',
|
||||
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
|
||||
}),
|
||||
]);
|
||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'weg',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -5,12 +8,9 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
type DashboardImageMime,
|
||||
@@ -314,6 +314,15 @@ export class DashboardImagesService {
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*
|
||||
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
|
||||
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
|
||||
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
|
||||
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
|
||||
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
|
||||
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
|
||||
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
|
||||
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -323,6 +332,19 @@ export class DashboardImagesService {
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
try {
|
||||
await tenantPrisma.user.updateMany({
|
||||
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
|
||||
data: { dashboardBackground: { kind: 'none' } },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const absolute = absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
|
||||
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
|
||||
}));
|
||||
|
||||
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
|
||||
// Erscheinungsbild und die Bilderliste.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ resolvedTheme: 'light' }),
|
||||
}));
|
||||
const fetchDashboardImages = vi.fn();
|
||||
vi.mock('@/lib/dashboard-images-api', () => ({
|
||||
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
|
||||
uploadDashboardImage: vi.fn(),
|
||||
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
|
||||
}));
|
||||
|
||||
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
|
||||
|
||||
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
|
||||
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
|
||||
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
|
||||
let rejectFirst: (e: Error) => void = () => {};
|
||||
updateDashboardBackgroundAction
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
||||
const { result } = await renderBackgroundHook();
|
||||
|
||||
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
|
||||
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
|
||||
await act(async () => {
|
||||
rejectFirst(new Error('offline'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
|
||||
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
||||
kind: 'preset',
|
||||
id: 'dunes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
|
||||
class FailingImage {
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onerror?.(), 0);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FailingImage);
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
|
||||
});
|
||||
|
||||
const { result } = await renderBackgroundHook();
|
||||
|
||||
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
|
||||
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein ladbares Bild bleibt gewaehlt', async () => {
|
||||
class LoadingImage {
|
||||
onerror: (() => void) | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', LoadingImage);
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
|
||||
});
|
||||
|
||||
const { result } = await renderBackgroundHook();
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
});
|
||||
|
||||
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundPicker (quick-260930)', () => {
|
||||
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
|
||||
const { BackgroundPicker } = await import('./dashboard-background');
|
||||
const onChange = vi.fn();
|
||||
render(<BackgroundPicker value={value} onChange={onChange} />);
|
||||
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchDashboardImages.mockReset();
|
||||
fetchDashboardImages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
|
||||
const { trigger } = await renderPicker({ kind: 'none' });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const first = screen.getByRole('button', { name: 'none' });
|
||||
expect(dialog).toContainElement(first);
|
||||
expect(first).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
|
||||
const { trigger } = await renderPicker({ kind: 'none' });
|
||||
fireEvent.click(trigger);
|
||||
await screen.findByRole('dialog');
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
|
||||
const { trigger } = await renderPicker({ kind: 'none' });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const buttons = Array.from(dialog.querySelectorAll('button'));
|
||||
const first = buttons[0];
|
||||
const last = buttons[buttons.length - 1];
|
||||
|
||||
last.focus();
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
expect(first).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
expect(last).toHaveFocus();
|
||||
});
|
||||
|
||||
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
|
||||
fetchDashboardImages.mockResolvedValue([
|
||||
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
|
||||
]);
|
||||
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await screen.findByRole('button', { name: 'urlaub.jpg' });
|
||||
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
|
||||
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
|
||||
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
|
||||
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
|
||||
* Klicks sonst die zweite Wahl ueberschreiben.
|
||||
*/
|
||||
function revertStoredBackground(
|
||||
userId: string,
|
||||
optimistic: DashboardBackground,
|
||||
previous: DashboardBackground | null,
|
||||
) {
|
||||
const current = useAuthStore.getState().user;
|
||||
if (!current || current.id !== userId) return;
|
||||
if (current.dashboardBackground !== optimistic) return;
|
||||
setStoredBackground(userId, previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
|
||||
*
|
||||
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
|
||||
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
|
||||
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
|
||||
* wird sie genau einmal je Benutzerkennung uebernommen.
|
||||
*
|
||||
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
|
||||
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
|
||||
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
|
||||
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
|
||||
* ist nur Anzeige; gespeichert wird dabei nichts.
|
||||
*/
|
||||
export function useDashboardBackground() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id ?? null;
|
||||
const stored = user?.dashboardBackground ?? null;
|
||||
const background = stored ?? NO_BACKGROUND;
|
||||
const imageId = stored?.kind === 'image' ? stored.imageId : null;
|
||||
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
|
||||
const background =
|
||||
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
|
||||
const migratedFor = useRef<string | null>(null);
|
||||
|
||||
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
|
||||
// Wahl als „kein Hintergrund“ angezeigt.
|
||||
useEffect(() => {
|
||||
if (imageId === null || typeof Image === 'undefined') return;
|
||||
let active = true;
|
||||
const probe = new Image();
|
||||
probe.onerror = () => {
|
||||
if (active) setBrokenImageId(imageId);
|
||||
};
|
||||
probe.src = dashboardImageSrc(imageId);
|
||||
return () => {
|
||||
active = false;
|
||||
probe.onerror = null;
|
||||
};
|
||||
}, [imageId]);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
|
||||
setStoredBackground(id, value);
|
||||
const revert = () => setStoredBackground(id, previous);
|
||||
const revert = () => revertStoredBackground(id, value, previous);
|
||||
updateDashboardBackgroundAction(value)
|
||||
.then((result) => {
|
||||
if (!result.success) revert();
|
||||
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
|
||||
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
|
||||
const [imagesListed, setImagesListed] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
|
||||
// (quick-260930).
|
||||
const shown: DashboardBackground =
|
||||
value.kind === 'image' &&
|
||||
imagesListed &&
|
||||
images !== null &&
|
||||
!images.some((image) => image.id === value.imageId)
|
||||
? NO_BACKGROUND
|
||||
: value;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || images !== null) return;
|
||||
fetchDashboardImages()
|
||||
.then(setImages)
|
||||
.then((list) => {
|
||||
setImages(list);
|
||||
setImagesListed(true);
|
||||
})
|
||||
.catch(() => setImages([]));
|
||||
}, [open, images]);
|
||||
|
||||
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
|
||||
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
|
||||
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
|
||||
return;
|
||||
}
|
||||
if (!wasOpen.current) return;
|
||||
wasOpen.current = false;
|
||||
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
|
||||
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
|
||||
const timer = window.setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (!active || active === document.body || rootRef.current?.contains(active)) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointer(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
|
||||
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const items = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (!active || !dialogRef.current.contains(active)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
preview: CSSProperties,
|
||||
content?: React.ReactNode,
|
||||
) => {
|
||||
const selected = sameChoice(value, choice);
|
||||
const selected = sameChoice(shown, choice);
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-label={t('title')}
|
||||
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
|
||||
@@ -137,9 +137,7 @@ describe('DashboardGrid', () => {
|
||||
});
|
||||
|
||||
it('renders widget instance when widgets are provided', async () => {
|
||||
const widgets = [
|
||||
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
|
||||
];
|
||||
const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
@@ -167,9 +165,7 @@ describe('DashboardGrid', () => {
|
||||
});
|
||||
|
||||
it('shows edit affordances (delete button) when isEditMode is true', async () => {
|
||||
const widgets = [
|
||||
{ id: 'inst-2', widgetType: 'clock', config: {} },
|
||||
];
|
||||
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
@@ -312,7 +308,7 @@ describe('DashboardGrid', () => {
|
||||
expect(layouts.lg[0].minH).toBe(8);
|
||||
});
|
||||
|
||||
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => {
|
||||
it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
|
||||
@@ -341,25 +337,108 @@ describe('DashboardGrid', () => {
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
|
||||
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
|
||||
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
|
||||
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 });
|
||||
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
|
||||
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
|
||||
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
|
||||
// Auch der data-grid-Wert der Kinder traegt das neue Minimum.
|
||||
const children = Children.toArray(captured.props?.children as React.ReactNode);
|
||||
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>;
|
||||
expect(grid.minW).toBe(8);
|
||||
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
|
||||
'data-grid'
|
||||
] as Record<string, unknown>;
|
||||
expect(grid.minW).toBe(11);
|
||||
});
|
||||
|
||||
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
|
||||
it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const layouts = {
|
||||
lg: [
|
||||
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
|
||||
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
|
||||
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
|
||||
// Kalender am rechten Rand: x 40 + 11 > 48.
|
||||
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
|
||||
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
|
||||
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
|
||||
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={[
|
||||
{ id: 'cal-1', widgetType: 'calendar', config: {} },
|
||||
{ id: 'clk-1', widgetType: 'clock', config: {} },
|
||||
{ id: 'cal-2', widgetType: 'calendar', config: {} },
|
||||
{ id: 'cal-3', widgetType: 'calendar', config: {} },
|
||||
{ id: 'clk-2', widgetType: 'clock', config: {} },
|
||||
]}
|
||||
isEditMode
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
|
||||
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
|
||||
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
|
||||
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
|
||||
});
|
||||
|
||||
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const layouts = {
|
||||
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
|
||||
xxs: [
|
||||
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
|
||||
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={[
|
||||
{ id: 'srch-1', widgetType: 'search', config: {} },
|
||||
{ id: 'cal-1', widgetType: 'calendar', config: {} },
|
||||
]}
|
||||
isEditMode
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
|
||||
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
|
||||
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
|
||||
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
|
||||
});
|
||||
|
||||
const CANCEL =
|
||||
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
|
||||
|
||||
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
|
||||
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
|
||||
captured.props = null;
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={true}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(captured.props?.dragConfig).toEqual({
|
||||
enabled: true,
|
||||
@@ -372,7 +451,13 @@ describe('DashboardGrid', () => {
|
||||
|
||||
captured.props = null;
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(captured.props?.dragConfig).toEqual({
|
||||
enabled: false,
|
||||
@@ -400,7 +485,10 @@ describe('DashboardGrid', () => {
|
||||
type: unknown;
|
||||
allowOverlap: boolean;
|
||||
preventCollision?: boolean;
|
||||
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
|
||||
compact: (
|
||||
layout: Array<Record<string, unknown>>,
|
||||
cols: number,
|
||||
) => Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
|
||||
expect(typeof compactor.compact).toBe('function');
|
||||
@@ -423,10 +511,22 @@ describe('DashboardGrid', () => {
|
||||
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
|
||||
|
||||
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
|
||||
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={true}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
|
||||
@@ -455,7 +555,13 @@ describe('DashboardGrid', () => {
|
||||
cleanup();
|
||||
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
|
||||
expect(document.querySelector('.widget-drag-handle')).toBeNull();
|
||||
@@ -512,7 +618,9 @@ describe('DashboardGrid', () => {
|
||||
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, 0, 1600, 800),
|
||||
);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
@@ -528,11 +636,16 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
|
||||
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
|
||||
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
|
||||
];
|
||||
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
|
||||
const context = { layout } as unknown as Parameters<
|
||||
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
|
||||
>[4];
|
||||
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
|
||||
|
||||
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
|
||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
|
||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
|
||||
w: 2,
|
||||
h: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('laesst eine freie Groesse unveraendert', () => {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Responsive, noCompactor } from 'react-grid-layout';
|
||||
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
||||
import { noCompactor, Responsive } from 'react-grid-layout';
|
||||
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
|
||||
import {
|
||||
clampWidthToCols,
|
||||
GRID_COLS,
|
||||
WIDGET_CONSTRAINTS,
|
||||
WIDGET_REGISTRY,
|
||||
type WidgetType,
|
||||
} from './widget-registry';
|
||||
import { WidgetIcon } from './widgets/widget-icon';
|
||||
import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
|
||||
@@ -23,7 +29,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
|
||||
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
|
||||
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
|
||||
// Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
|
||||
// weil auch der Store sie fuer neue Kacheln braucht.
|
||||
const COLS = GRID_COLS;
|
||||
|
||||
// quick-260916-dyv: Ziehen zuverlaessig.
|
||||
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
|
||||
@@ -138,6 +146,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
|
||||
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
|
||||
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
|
||||
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
|
||||
*
|
||||
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
|
||||
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
|
||||
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
|
||||
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
|
||||
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
|
||||
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
|
||||
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
|
||||
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
|
||||
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
|
||||
* verschoben ist.
|
||||
*/
|
||||
function applyConstraintMinima(
|
||||
layouts: Record<string, Array<LayoutItemShape>>,
|
||||
@@ -150,18 +169,42 @@ function applyConstraintMinima(
|
||||
for (const key of Object.keys(layouts)) {
|
||||
const entries = layouts[key];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
result[key] = entries.map((entry) => {
|
||||
const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
|
||||
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
|
||||
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
|
||||
const targetW = new Map<string, number>();
|
||||
const next = entries.map((entry) => {
|
||||
const type = typeById.get(entry.i);
|
||||
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
|
||||
if (!constraints) return { ...entry };
|
||||
const minW = clampWidthToCols(constraints.minW, key);
|
||||
const w = Math.min(entry.w, cols);
|
||||
if (w < minW) targetW.set(entry.i, minW);
|
||||
return {
|
||||
...entry,
|
||||
w: Math.max(entry.w, constraints.minW),
|
||||
w,
|
||||
h: Math.max(entry.h, constraints.minH),
|
||||
minW: constraints.minW,
|
||||
minW,
|
||||
minH: constraints.minH,
|
||||
};
|
||||
});
|
||||
|
||||
for (const item of next) {
|
||||
const target = targetW.get(item.i);
|
||||
if (target === undefined) continue;
|
||||
const others = next.filter((o) => o !== item);
|
||||
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
|
||||
// entstehen nicht durch das Anheben.
|
||||
const blockers = others.filter((o) => !overlaps(item, o));
|
||||
while (
|
||||
item.w < target &&
|
||||
item.x + item.w + 1 <= cols &&
|
||||
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
|
||||
) {
|
||||
item.w += 1;
|
||||
}
|
||||
}
|
||||
result[key] = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -259,7 +302,10 @@ export function DashboardGrid({
|
||||
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
||||
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
||||
// stabil bleibt.
|
||||
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
|
||||
const effectiveLayouts = useMemo(
|
||||
() => applyConstraintMinima(layouts, widgets),
|
||||
[layouts, widgets],
|
||||
);
|
||||
|
||||
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
|
||||
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
||||
@@ -269,7 +315,18 @@ export function DashboardGrid({
|
||||
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
|
||||
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
|
||||
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1.5" />
|
||||
@@ -296,7 +353,19 @@ export function DashboardGrid({
|
||||
)}
|
||||
{onOpenCatalog && (
|
||||
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{t('addWidget')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
|
||||
// Stift gesperrt und bittet ums Neuladen.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
|
||||
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
import { EditModeToggle } from './edit-mode-toggle';
|
||||
|
||||
beforeEach(() => {
|
||||
storeState.layoutFromNewerVersion = false;
|
||||
});
|
||||
|
||||
describe('EditModeToggle (quick-260930)', () => {
|
||||
it('normal: Stift ist bedienbar', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
|
||||
const button = screen.getByRole('button', { name: 'editShort' });
|
||||
expect(button).toBeEnabled();
|
||||
fireEvent.click(button);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
|
||||
storeState.layoutFromNewerVersion = true;
|
||||
const onToggle = vi.fn();
|
||||
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
|
||||
const button = screen.getByRole('button', { name: 'editShort' });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('title', 'reloadRequired');
|
||||
expect(button).toHaveAccessibleDescription('reloadRequired');
|
||||
fireEvent.click(button);
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useId } from 'react';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
|
||||
interface EditModeToggleProps {
|
||||
isEditMode: boolean;
|
||||
@@ -13,55 +15,72 @@ interface EditModeToggleProps {
|
||||
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
|
||||
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
|
||||
* der gelbe Knopf „Fertig“.
|
||||
*
|
||||
* quick-260930: Stammt die geladene Anordnung von einem neueren
|
||||
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
|
||||
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
|
||||
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
|
||||
* ueberschreiben.
|
||||
*/
|
||||
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
|
||||
const hintId = useId();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
|
||||
aria-pressed={isEditMode}
|
||||
aria-label={isEditMode ? undefined : t('editShort')}
|
||||
title={isEditMode ? t('saveChanges') : t('editMode')}
|
||||
>
|
||||
{isEditMode ? (
|
||||
// Checkmark icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : null}
|
||||
{isEditMode ? null : (
|
||||
// Pencil icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={locked}
|
||||
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
aria-pressed={isEditMode}
|
||||
aria-label={isEditMode ? undefined : t('editShort')}
|
||||
aria-describedby={locked ? hintId : undefined}
|
||||
title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
|
||||
>
|
||||
{isEditMode ? (
|
||||
// Checkmark icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : null}
|
||||
{isEditMode ? null : (
|
||||
// Pencil icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
)}
|
||||
{isEditMode ? t('done') : null}
|
||||
</button>
|
||||
{locked && (
|
||||
<span id={hintId} className="sr-only">
|
||||
{t('reloadRequired')}
|
||||
</span>
|
||||
)}
|
||||
{isEditMode ? t('done') : null}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clampWidthToCols,
|
||||
GRID_COLS,
|
||||
registerWidget,
|
||||
visibleWidgetTypes,
|
||||
WIDGET_CONSTRAINTS,
|
||||
WIDGET_REGISTRY,
|
||||
type WidgetDefinition,
|
||||
type WidgetProps,
|
||||
type WidgetType,
|
||||
registerWidget,
|
||||
visibleWidgetTypes,
|
||||
} from './widget-registry';
|
||||
|
||||
/**
|
||||
@@ -38,29 +40,31 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
it('contains entries for all expected widget types', () => {
|
||||
for (const type of ALL_WIDGET_TYPES) {
|
||||
expect(
|
||||
WIDGET_CONSTRAINTS,
|
||||
`WIDGET_CONSTRAINTS must contain key "${type}"`,
|
||||
).toHaveProperty(type);
|
||||
expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
|
||||
type,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ALL_WIDGET_TYPES)(
|
||||
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH',
|
||||
(type) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[type];
|
||||
it.each(
|
||||
ALL_WIDGET_TYPES,
|
||||
)('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[type];
|
||||
|
||||
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
|
||||
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
|
||||
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
|
||||
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
|
||||
|
||||
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW);
|
||||
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH);
|
||||
},
|
||||
);
|
||||
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
|
||||
constraints.minW,
|
||||
);
|
||||
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
|
||||
constraints.minH,
|
||||
);
|
||||
});
|
||||
|
||||
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
|
||||
@@ -71,12 +75,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
|
||||
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
|
||||
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
|
||||
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
|
||||
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24).
|
||||
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
|
||||
// (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
|
||||
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
|
||||
expect(WIDGET_CONSTRAINTS).toEqual({
|
||||
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
|
||||
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
|
||||
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
|
||||
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
|
||||
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
|
||||
@@ -98,6 +103,38 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kalender-minW (quick-260930)', () => {
|
||||
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
|
||||
const margin = 12;
|
||||
const cols = GRID_COLS.lg;
|
||||
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
|
||||
const px = (w: number) => w * colWidth + (w - 1) * margin;
|
||||
const { minW } = WIDGET_CONSTRAINTS.calendar;
|
||||
expect(px(minW)).toBeGreaterThanOrEqual(250);
|
||||
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
|
||||
expect(px(minW - 1)).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampWidthToCols (quick-260930)', () => {
|
||||
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
|
||||
expect(clampWidthToCols(24, 'xxs')).toBe(4);
|
||||
expect(clampWidthToCols(24, 'xs')).toBe(16);
|
||||
expect(clampWidthToCols(24, 'lg')).toBe(24);
|
||||
expect(clampWidthToCols(3, 'xxs')).toBe(3);
|
||||
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
|
||||
});
|
||||
|
||||
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
|
||||
for (const bp of Object.keys(GRID_COLS)) {
|
||||
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
|
||||
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
|
||||
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
|
||||
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
|
||||
@@ -57,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
|
||||
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
|
||||
// den Monat nur ein Termin.
|
||||
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von
|
||||
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster,
|
||||
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px
|
||||
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite.
|
||||
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
|
||||
// rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
|
||||
// bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
|
||||
// damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
|
||||
// der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
|
||||
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
|
||||
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
|
||||
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
|
||||
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
|
||||
// soweit rechts Platz ist (applyConstraintMinima).
|
||||
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
|
||||
// Phase 8 new widgets (D-01)
|
||||
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
|
||||
@@ -98,6 +104,24 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
|
||||
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
|
||||
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
|
||||
*/
|
||||
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
|
||||
|
||||
/**
|
||||
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
|
||||
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
|
||||
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
|
||||
* Breakpoints bleiben unbegrenzt.
|
||||
*/
|
||||
export function clampWidthToCols(width: number, breakpoint: string): number {
|
||||
const cols = GRID_COLS[breakpoint];
|
||||
return cols === undefined ? width : Math.min(width, cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition for a widget type in the catalog.
|
||||
*/
|
||||
|
||||
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
|
||||
const toggle = screen.getByRole('button', { name: 'hideTitle' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(toggle);
|
||||
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
|
||||
// im Bearbeitungsmodus bleibt der Titel sichtbar
|
||||
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
|
||||
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
|
||||
// quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
|
||||
const toggle = screen.getByRole('button', { name: 'hideTitle' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
|
||||
|
||||
rerender(
|
||||
|
||||
@@ -48,7 +48,12 @@ interface WidgetWrapperProps {
|
||||
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
|
||||
* Renders the actual widget component via WIDGET_REGISTRY lookup.
|
||||
*/
|
||||
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
|
||||
export function WidgetWrapper({
|
||||
widget,
|
||||
isEditMode,
|
||||
onRemove,
|
||||
enterIndex = 0,
|
||||
}: WidgetWrapperProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||
const WidgetComponent = definition?.component;
|
||||
@@ -103,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
||||
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
|
||||
}}
|
||||
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
|
||||
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||
title={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||
// quick-260930: feste Beschriftung plus aria-pressed — ein
|
||||
// Umschalter, dessen Name mit dem Zustand wechselt, liest sich
|
||||
// doppelt („Titel einblenden, gedrueckt“).
|
||||
aria-label={t('hideTitle')}
|
||||
title={t('hideTitle')}
|
||||
aria-pressed={titleHidden}
|
||||
data-no-drag=""
|
||||
>
|
||||
@@ -167,31 +175,29 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
||||
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
|
||||
aendert die Hoehenkette nicht. */}
|
||||
<div className="flex h-full flex-col">
|
||||
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
|
||||
<div className="widget-head" data-testid="widget-frame-head">
|
||||
<WidgetIcon type={widget.widgetType} />
|
||||
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="@container-size min-h-0 flex-1">
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
config={widget.config}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
|
||||
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
|
||||
<div className="widget-head" data-testid="widget-frame-head">
|
||||
<WidgetIcon type={widget.widgetType} />
|
||||
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="@container-size min-h-0 flex-1">
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
config={widget.config}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
|
||||
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
|
||||
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
|
||||
stand hier der rohe Typname, der dem Anwender nichts sagte. */
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user