style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
Spaltenmindestbreite 76 -> 52 px (Abstand zwischen den Symbolen ~59 -> ~27 px bei einer 373 px breiten Kachel). LauncherLabel misst den Titel einzeilig in 12 px; passt er nicht, 10 px, zweizeilig mit Silbentrennung und Auslassung. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Geändert
|
### 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.
|
- 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.
|
- 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.
|
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
|
||||||
|
|||||||
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
|
|||||||
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
import {
|
import {
|
||||||
|
createFavorite,
|
||||||
|
deleteFavorite,
|
||||||
FavoriteRequestError,
|
FavoriteRequestError,
|
||||||
fetchFavorites,
|
fetchFavorites,
|
||||||
createFavorite,
|
|
||||||
updateFavorite,
|
|
||||||
deleteFavorite,
|
|
||||||
reorderFavorites,
|
|
||||||
uploadFavoriteIcon,
|
|
||||||
removeFavoriteIcon,
|
removeFavoriteIcon,
|
||||||
|
reorderFavorites,
|
||||||
|
updateFavorite,
|
||||||
|
uploadFavoriteIcon,
|
||||||
} from '@/lib/favorites-api';
|
} from '@/lib/favorites-api';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
|
||||||
|
|
||||||
// Must import AFTER mocks
|
// Must import AFTER mocks
|
||||||
import { FavoritesWidget } from './favorites-widget';
|
import { FavoritesWidget, LauncherLabel } from './favorites-widget';
|
||||||
|
|
||||||
// Typed mock helpers
|
// Typed mock helpers
|
||||||
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
||||||
@@ -106,13 +106,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
|
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
||||||
expect(mockFetch).toHaveBeenCalledWith('fav-1');
|
expect(mockFetch).toHaveBeenCalledWith('fav-1');
|
||||||
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
|
|||||||
it('shows empty message when fetchFavorites resolves to empty array', async () => {
|
it('shows empty message when fetchFavorites resolves to empty array', async () => {
|
||||||
mockFetch.mockResolvedValue([]);
|
mockFetch.mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
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 () => {
|
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
|
||||||
mockFetch.mockResolvedValue([]);
|
mockFetch.mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
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 () => {
|
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('view toggle: switching to grid in edit mode renders grid container', async () => {
|
it('view toggle: switching to grid in edit mode renders grid container', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ viewMode: 'list' }}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
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 () => {
|
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||||
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
|
|||||||
// quick-260916-iex: optionaler Titel
|
// quick-260916-iex: optionaler Titel
|
||||||
describe('optionaler Titel (quick-260916-iex)', () => {
|
describe('optionaler Titel (quick-260916-iex)', () => {
|
||||||
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
||||||
const r1 = render(
|
const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
expect(
|
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
|
||||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
r1.unmount();
|
r1.unmount();
|
||||||
|
|
||||||
const r2 = render(
|
const r2 = render(
|
||||||
<FavoritesWidget
|
<FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ title: ' ' }}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
r2.unmount();
|
r2.unmount();
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ title: 42 }}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
||||||
render(
|
render(
|
||||||
<FavoritesWidget
|
<FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ title: 'Werkzeuge' }}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(heading.tagName).toBe('H2');
|
expect(heading.tagName).toBe('H2');
|
||||||
expect(heading.className).toContain('text-sm');
|
expect(heading.className).toContain('text-sm');
|
||||||
expect(heading.className).toContain('font-semibold');
|
expect(heading.className).toContain('font-semibold');
|
||||||
expect(
|
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
|
||||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(titleField.className).toContain('widgetNoDrag');
|
expect(titleField.className).toContain('widgetNoDrag');
|
||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(
|
expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
|
||||||
screen.getByRole('button', { name: /favorites\.listView/i }),
|
expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: /favorites\.gridView/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
||||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||||
try {
|
try {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
const titleField = screen.getByPlaceholderText(
|
const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
|
||||||
'favorites.titlePlaceholder',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const value of ['W', 'We', 'Werkzeuge']) {
|
for (const value of ['W', 'We', 'Werkzeuge']) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
|
|||||||
// Sortierpfeile im Bearbeitungsmodus.
|
// Sortierpfeile im Bearbeitungsmodus.
|
||||||
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
||||||
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||||
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -561,12 +485,17 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
it('kein Direktbild bei Nicht-http-URL', async () => {
|
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||||
mockFetch.mockResolvedValue([
|
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(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
||||||
@@ -583,9 +512,7 @@ describe('FavoritesWidget', () => {
|
|||||||
{ ...BASE_FAVORITES[0], position: 1 },
|
{ ...BASE_FAVORITES[0], position: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -618,9 +545,7 @@ describe('FavoritesWidget', () => {
|
|||||||
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
||||||
mockReorder.mockRejectedValue(new Error('boom'));
|
mockReorder.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -656,9 +581,7 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
rerender(
|
rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
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 });
|
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
|
||||||
mockUploadIcon.mockResolvedValue({
|
mockUploadIcon.mockResolvedValue({
|
||||||
...BASE_FAVORITES[0],
|
...BASE_FAVORITES[0],
|
||||||
@@ -789,7 +712,10 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
'fav-id-1',
|
||||||
|
expect.objectContaining({ title: 'GitHub' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
||||||
@@ -857,12 +783,16 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
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([
|
mockFetch.mockResolvedValue([
|
||||||
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
||||||
BASE_FAVORITES[1],
|
BASE_FAVORITES[1],
|
||||||
]);
|
]);
|
||||||
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
|
mockRemoveIcon.mockResolvedValue({
|
||||||
|
...BASE_FAVORITES[0],
|
||||||
|
uploadedIconMime: null,
|
||||||
|
iconVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
|
|
||||||
@@ -881,7 +811,9 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
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();
|
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -895,7 +827,9 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
openEditForGitHub();
|
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 () => {
|
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
|
||||||
@@ -935,7 +869,11 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreate).toHaveBeenCalledWith(
|
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(() => {
|
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(<LauncherLabel text="Wiki" />);
|
||||||
|
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(<LauncherLabel text="Reisekostenabrechnung" />);
|
||||||
|
const label = screen.getByText('Reisekostenabrechnung');
|
||||||
|
expect(label).toHaveAttribute('data-small', 'true');
|
||||||
|
expect(label.className).toContain('text-[10px]');
|
||||||
|
expect(label.className).toContain('line-clamp-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { WidgetIcon } from './widget-icon';
|
|
||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
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 { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
import {
|
import {
|
||||||
FavoriteRequestError,
|
|
||||||
createFavorite,
|
createFavorite,
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
|
type FavoriteLink,
|
||||||
|
FavoriteRequestError,
|
||||||
fetchFavorites,
|
fetchFavorites,
|
||||||
removeFavoriteIcon,
|
removeFavoriteIcon,
|
||||||
reorderFavorites,
|
reorderFavorites,
|
||||||
updateFavorite,
|
updateFavorite,
|
||||||
uploadFavoriteIcon,
|
uploadFavoriteIcon,
|
||||||
type FavoriteLink,
|
|
||||||
} from '@/lib/favorites-api';
|
} 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
|
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
|
||||||
const TITLE_DEBOUNCE_MS = 1500;
|
const TITLE_DEBOUNCE_MS = 1500;
|
||||||
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
|
|||||||
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
|
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
|
||||||
* persistiert ueber PUT /favorites/order
|
* persistiert ueber PUT /favorites/order
|
||||||
*/
|
*/
|
||||||
export function FavoritesWidget({
|
export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||||
instanceId,
|
|
||||||
config,
|
|
||||||
isEditMode,
|
|
||||||
}: WidgetProps) {
|
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|
||||||
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
||||||
@@ -68,12 +64,8 @@ export function FavoritesWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Optionaler Titel (quick-260916-iex)
|
// Optionaler Titel (quick-260916-iex)
|
||||||
const [title, setTitle] = useState<string>(
|
const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
|
||||||
typeof config.title === 'string' ? config.title : '',
|
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
);
|
|
||||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => clearTimeout(titleTimerRef.current);
|
return () => clearTimeout(titleTimerRef.current);
|
||||||
@@ -335,15 +327,15 @@ export function FavoritesWidget({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
hasTitle && (
|
hasTitle && (
|
||||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
|
||||||
{title.trim()}
|
|
||||||
</h2>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
|
<div
|
||||||
|
className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
|
||||||
|
>
|
||||||
{/* View mode toggle (edit mode only) */}
|
{/* View mode toggle (edit mode only) */}
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
||||||
@@ -382,23 +374,20 @@ export function FavoritesWidget({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status messages */}
|
{/* Status messages */}
|
||||||
{loading && (
|
{loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
|
||||||
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Favorites list / grid */}
|
{/* Favorites list / grid */}
|
||||||
{!loading && sortedFavorites.length === 0 && (
|
{!loading && sortedFavorites.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
|
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && sortedFavorites.length > 0 && (
|
{!loading &&
|
||||||
viewMode === 'grid' ? (
|
sortedFavorites.length > 0 &&
|
||||||
|
(viewMode === 'grid' ? (
|
||||||
<div
|
<div
|
||||||
data-testid="favorites-grid"
|
data-testid="favorites-grid"
|
||||||
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
|
className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
|
||||||
>
|
>
|
||||||
{sortedFavorites.map((fav, index) => (
|
{sortedFavorites.map((fav, index) => (
|
||||||
<FavoriteTile
|
<FavoriteTile
|
||||||
@@ -430,10 +419,7 @@ export function FavoritesWidget({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div data-testid="favorites-list" className="flex flex-col gap-0.5">
|
||||||
data-testid="favorites-list"
|
|
||||||
className="flex flex-col gap-0.5"
|
|
||||||
>
|
|
||||||
{sortedFavorites.map((fav, index) => (
|
{sortedFavorites.map((fav, index) => (
|
||||||
<FavoriteTile
|
<FavoriteTile
|
||||||
key={fav.id}
|
key={fav.id}
|
||||||
@@ -462,8 +448,7 @@ export function FavoritesWidget({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add form (edit mode only, D-04) */}
|
{/* Add form (edit mode only, D-04) */}
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
@@ -677,6 +662,60 @@ type FavoriteTileProps = {
|
|||||||
launcher?: boolean;
|
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<HTMLSpanElement>(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 (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
data-small={small ? 'true' : undefined}
|
||||||
|
className={`line-clamp-2 w-full break-words hyphens-auto ${
|
||||||
|
small ? 'text-[10px] leading-3' : 'text-xs leading-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FavoriteTile({
|
function FavoriteTile({
|
||||||
fav,
|
fav,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
@@ -728,9 +767,11 @@ function FavoriteTile({
|
|||||||
large={launcher}
|
large={launcher}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
|
{launcher ? (
|
||||||
{fav.title}
|
<LauncherLabel text={fav.title} />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="truncate text-sm">{fav.title}</span>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Action buttons (edit mode only, D-04) */}
|
{/* Action buttons (edit mode only, D-04) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user