diff --git a/CHANGELOG.md b/CHANGELOG.md index 0165a1c..5ffa646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Geändert +- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen. - Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander. - Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt. - Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird. diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx index 6985879..5393306 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({ updateWidgetConfig: vi.fn().mockResolvedValue(undefined), })); +import { updateWidgetConfig } from '@/lib/dashboard-api'; import { + createFavorite, + deleteFavorite, FavoriteRequestError, fetchFavorites, - createFavorite, - updateFavorite, - deleteFavorite, - reorderFavorites, - uploadFavoriteIcon, removeFavoriteIcon, + reorderFavorites, + updateFavorite, + uploadFavoriteIcon, } from '@/lib/favorites-api'; -import { updateWidgetConfig } from '@/lib/dashboard-api'; // Must import AFTER mocks -import { FavoritesWidget } from './favorites-widget'; +import { FavoritesWidget, LauncherLabel } from './favorites-widget'; // Typed mock helpers const mockFetch = fetchFavorites as ReturnType; @@ -106,13 +106,7 @@ describe('FavoritesWidget', () => { }); it('calls fetchFavorites with instanceId on mount and renders link titles', async () => { - render( - , - ); + render(); // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) expect(mockFetch).toHaveBeenCalledWith('fav-1'); @@ -127,13 +121,7 @@ describe('FavoritesWidget', () => { it('shows empty message when fetchFavorites resolves to empty array', async () => { mockFetch.mockResolvedValue([]); - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('favorites.empty')).toBeInTheDocument(); @@ -143,13 +131,7 @@ describe('FavoritesWidget', () => { it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => { mockFetch.mockResolvedValue([]); - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('favorites.empty')).toBeInTheDocument(); @@ -189,13 +171,7 @@ describe('FavoritesWidget', () => { }); it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -239,13 +215,7 @@ describe('FavoritesWidget', () => { }); it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -271,13 +241,7 @@ describe('FavoritesWidget', () => { }); it('view toggle: switching to grid in edit mode renders grid container', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -299,13 +263,7 @@ describe('FavoritesWidget', () => { }); it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('Notion')).toBeInTheDocument(); @@ -318,24 +276,16 @@ describe('FavoritesWidget', () => { // quick-260916-iex: optionaler Titel describe('optionaler Titel (quick-260916-iex)', () => { it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => { - const r1 = render( - , - ); + const r1 = render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); }); expect(screen.queryByRole('heading')).not.toBeInTheDocument(); - expect( - screen.queryByPlaceholderText('favorites.titlePlaceholder'), - ).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument(); r1.unmount(); const r2 = render( - , + , ); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -343,13 +293,7 @@ describe('FavoritesWidget', () => { expect(screen.queryByRole('heading')).not.toBeInTheDocument(); r2.unmount(); - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); }); @@ -358,11 +302,7 @@ describe('FavoritesWidget', () => { it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => { render( - , + , ); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -371,15 +311,11 @@ describe('FavoritesWidget', () => { expect(heading.tagName).toBe('H2'); expect(heading.className).toContain('text-sm'); expect(heading.className).toContain('font-semibold'); - expect( - screen.queryByPlaceholderText('favorites.titlePlaceholder'), - ).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument(); }); it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); }); @@ -391,25 +327,17 @@ describe('FavoritesWidget', () => { expect(titleField.className).toContain('widgetNoDrag'); expect(screen.queryByRole('heading')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /favorites\.listView/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /favorites\.gridView/i }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument(); }); it('entprellt das Speichern des Titels um 1500 ms', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); try { - render( - , - ); + render(); await act(async () => {}); - const titleField = screen.getByPlaceholderText( - 'favorites.titlePlaceholder', - ); + const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder'); for (const value of ['W', 'We', 'Werkzeuge']) { await act(async () => { @@ -444,9 +372,7 @@ describe('FavoritesWidget', () => { // Sortierpfeile im Bearbeitungsmodus. describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => { it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('Notion')).toBeInTheDocument(); @@ -460,9 +386,7 @@ describe('FavoritesWidget', () => { }); it('Kette Proxy -> direkt -> Buchstabe', async () => { - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -561,12 +485,17 @@ describe('FavoritesWidget', () => { it('kein Direktbild bei Nicht-http-URL', async () => { mockFetch.mockResolvedValue([ - { id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }, + { + id: 'fav-id-3', + widgetId: 'fav-1', + title: 'Ablage', + url: 'ftp://files.example', + iconUrl: null, + position: 0, + }, ]); - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('Ablage')).toBeInTheDocument(); @@ -583,9 +512,7 @@ describe('FavoritesWidget', () => { { ...BASE_FAVORITES[0], position: 1 }, ]); - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -618,9 +545,7 @@ describe('FavoritesWidget', () => { it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => { mockReorder.mockRejectedValue(new Error('boom')); - render( - , - ); + render(); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); @@ -656,9 +581,7 @@ describe('FavoritesWidget', () => { await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - rerender( - , - ); + rerender(); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); }); @@ -761,7 +684,7 @@ describe('FavoritesWidget', () => { }); }); - it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => { + it("Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Formular schliesst", async () => { mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 }); mockUploadIcon.mockResolvedValue({ ...BASE_FAVORITES[0], @@ -789,7 +712,10 @@ describe('FavoritesWidget', () => { }); await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' })); + expect(mockUpdate).toHaveBeenCalledWith( + 'fav-id-1', + expect.objectContaining({ title: 'GitHub' }), + ); }); await waitFor(() => { expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file); @@ -857,12 +783,16 @@ describe('FavoritesWidget', () => { expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); }); - it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => { + it("Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen", async () => { mockFetch.mockResolvedValue([ { ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 }, BASE_FAVORITES[1], ]); - mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 }); + mockRemoveIcon.mockResolvedValue({ + ...BASE_FAVORITES[0], + uploadedIconMime: null, + iconVersion: 2, + }); render(); @@ -881,7 +811,9 @@ describe('FavoritesWidget', () => { expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1'); }); await waitFor(() => { - expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }), + ).not.toBeInTheDocument(); }); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); }); @@ -895,7 +827,9 @@ describe('FavoritesWidget', () => { openEditForGitHub(); - expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }), + ).not.toBeInTheDocument(); }); it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => { @@ -935,7 +869,11 @@ describe('FavoritesWidget', () => { await waitFor(() => { expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }), + expect.objectContaining({ + widgetId: 'fav-1', + title: 'New Link', + url: 'https://example.com', + }), ); }); await waitFor(() => { @@ -947,3 +885,31 @@ describe('FavoritesWidget', () => { }); }); }); + +describe('LauncherLabel — Beschriftung in der Kachelansicht', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mockWidths(scroll: number, client: number) { + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scroll); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(client); + } + + it('bleibt in normaler Groesse, wenn der Titel in eine Zeile passt', () => { + mockWidths(40, 60); + render(); + const label = screen.getByText('Wiki'); + expect(label).not.toHaveAttribute('data-small'); + expect(label.className).toContain('text-xs'); + }); + + it('wird kleiner und zweizeilig, wenn der Titel nicht in eine Zeile passt', () => { + mockWidths(120, 60); + render(); + const label = screen.getByText('Reisekostenabrechnung'); + expect(label).toHaveAttribute('data-small', 'true'); + expect(label.className).toContain('text-[10px]'); + expect(label.className).toContain('line-clamp-2'); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx index 4f4826f..a14d5f5 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -1,21 +1,21 @@ 'use client'; -import { WidgetIcon } from './widget-icon'; -import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { WidgetProps } from '@/components/dashboard/widget-registry'; import { updateWidgetConfig } from '@/lib/dashboard-api'; import { - FavoriteRequestError, createFavorite, deleteFavorite, + type FavoriteLink, + FavoriteRequestError, fetchFavorites, removeFavoriteIcon, reorderFavorites, updateFavorite, uploadFavoriteIcon, - type FavoriteLink, } from '@/lib/favorites-api'; -import type { WidgetProps } from '@/components/dashboard/widget-registry'; +import { WidgetIcon } from './widget-icon'; // quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget const TITLE_DEBOUNCE_MS = 1500; @@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string { * - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch, * persistiert ueber PUT /favorites/order */ -export function FavoritesWidget({ - instanceId, - config, - isEditMode, -}: WidgetProps) { +export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) { const t = useTranslations('widgets'); const [favorites, setFavorites] = useState([]); @@ -68,12 +64,8 @@ export function FavoritesWidget({ ); // Optionaler Titel (quick-260916-iex) - const [title, setTitle] = useState( - typeof config.title === 'string' ? config.title : '', - ); - const titleTimerRef = useRef | undefined>( - undefined, - ); + const [title, setTitle] = useState(typeof config.title === 'string' ? config.title : ''); + const titleTimerRef = useRef | undefined>(undefined); useEffect(() => { return () => clearTimeout(titleTimerRef.current); @@ -335,174 +327,167 @@ export function FavoritesWidget({ /> ) : ( hasTitle && ( -

- {title.trim()} -

+

{title.trim()}

) )} )} -
- {/* View mode toggle (edit mode only) */} - {isEditMode && ( - // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") - // war sachlich falsch fuer einen Umschalter zwischen Listen- und - // Kachelansicht — hier greift D-03 im Sinn des Streichens (die - // Beschriftung trug keine echte Bedeutung), eine richtige - // Beschriftung ist trotzdem besser als gar keine. -
- - -
- )} - - {/* Status messages */} - {loading && ( -

{t('favorites.loading')}

- )} - {error && ( -

{error}

- )} - - {/* Favorites list / grid */} - {!loading && sortedFavorites.length === 0 && ( -

{t('favorites.empty')}

- )} - - {!loading && sortedFavorites.length > 0 && ( - viewMode === 'grid' ? ( +
+ {/* View mode toggle (edit mode only) */} + {isEditMode && ( + // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") + // war sachlich falsch fuer einen Umschalter zwischen Listen- und + // Kachelansicht — hier greift D-03 im Sinn des Streichens (die + // Beschriftung trug keine echte Bedeutung), eine richtige + // Beschriftung ist trotzdem besser als gar keine.
- {sortedFavorites.map((fav, index) => ( - 0} - canMoveDown={index < sortedFavorites.length - 1} - onMove={(fid, dir) => void handleMove(fid, dir)} - t={t} - /> - ))} + +
- ) : ( -
- {sortedFavorites.map((fav, index) => ( - 0} - canMoveDown={index < sortedFavorites.length - 1} - onMove={(fid, dir) => void handleMove(fid, dir)} - t={t} - /> - ))} -
- ) - )} + )} - {/* Add form (edit mode only, D-04) */} - {isEditMode && ( -
void handleAdd(e)} - className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag" - > - setNewTitle(e.target.value)} - placeholder={t('favorites.addTitle')} - /> - setNewUrl(e.target.value)} - placeholder={t('favorites.addUrl')} - /> - {/* 260923-lrr: eigenes Symbol beim Hinzufuegen */} - - -
- )} + setNewUrl(e.target.value)} + placeholder={t('favorites.addUrl')} + /> + {/* 260923-lrr: eigenes Symbol beim Hinzufuegen */} + + + + )}
); @@ -677,6 +662,60 @@ type FavoriteTileProps = { launcher?: boolean; }; +/** + * Beschriftung einer Favoriten-Kachel in der Kachelansicht. Passt der Titel + * in normaler Groesse (12 px) nicht in eine Zeile, wuerde er an die + * Nachbarkachel stossen bzw. mitten im Wort umbrechen — dann wird die Schrift + * kleiner (10 px) und darf zweizeilig umbrechen (Silbentrennung, danach "…"). + * Gemessen wird immer in normaler Groesse und einzeilig, damit das Ergebnis + * nicht von der eigenen Umschaltung abhaengt (keine Rueckkopplung). Der + * ResizeObserver misst neu, wenn die Kachel breiter oder schmaler wird; + * scrollWidth/clientWidth sind Layoutmasse und von der Dashboard-Skalierung + * (transform) unberuehrt. + */ +export function LauncherLabel({ text }: { text: string }) { + const ref = useRef(null); + const [small, setSmall] = useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: neuer Titel = neu messen, der Wert selbst steht im DOM + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const measure = () => { + const prev = { + whiteSpace: el.style.whiteSpace, + fontSize: el.style.fontSize, + display: el.style.display, + }; + el.style.whiteSpace = 'nowrap'; + el.style.fontSize = '12px'; + el.style.display = 'block'; + const overflow = el.scrollWidth > el.clientWidth + 1; + el.style.whiteSpace = prev.whiteSpace; + el.style.fontSize = prev.fontSize; + el.style.display = prev.display; + setSmall(overflow); + }; + measure(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [text]); + + return ( + + {text} + + ); +} + function FavoriteTile({ fav, isEditMode, @@ -728,9 +767,11 @@ function FavoriteTile({ large={launcher} /> - - {fav.title} - + {launcher ? ( + + ) : ( + {fav.title} + )} {/* Action buttons (edit mode only, D-04) */}