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