feat(08-03): FavoritesWidget frontend + API client + page.tsx wiring (GREEN)

- favorites-api.ts: FavoriteLink type + fetchFavorites/createFavorite/updateFavorite/deleteFavorite
  all use credentials: include and API_URL/favorites
- favorites-widget.tsx: list/grid view, inline add/edit/delete in edit mode,
  icon + letter fallback, rel=noreferrer + target=_blank, no dangerouslySetInnerHTML (T-08-07)
- useEffect deps fixed to [instanceId] only — excludes t() to prevent re-fetch on each render
- page.tsx: wireFavoritesWidget(FavoritesWidget) wired
- Full test suite: 81/81 pass (17 test files)
- Web TypeScript: clean
This commit is contained in:
2026-07-01 10:34:11 +02:00
parent 758d246e98
commit cc6f5ae893
4 changed files with 579 additions and 21 deletions
@@ -132,25 +132,28 @@ describe('FavoritesWidget', () => {
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
await act(async () => {
// Use sync fireEvent — RTL will flush state updates automatically
act(() => {
fireEvent.change(titleInput, { target: { value: 'New Link' } });
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
});
// Submit the form (button with text 'favorites.addButton')
// Submit the form
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
await act(async () => {
act(() => {
fireEvent.click(addButton);
});
// createFavorite must be called with the widgetId and the entered values
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
}),
);
// waitFor polls until the async chain (createFavorite → setFavorites) completes
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
}),
);
});
// The new link title should appear in the list
await waitFor(() => {
@@ -175,29 +178,37 @@ describe('FavoritesWidget', () => {
const editButtons = screen.getAllByRole('button', {
name: /favorites\.editButton/i,
});
await act(async () => {
act(() => {
fireEvent.click(editButtons[0]);
});
// Change the title in the inline edit form
await waitFor(() => {
expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument();
});
const editTitleInput = screen.getByDisplayValue('GitHub');
await act(async () => {
act(() => {
fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } });
});
// Click Save
await waitFor(() => {
expect(screen.getByRole('button', { name: /favorites\.saveButton/i })).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', {
name: /favorites\.saveButton/i,
});
await act(async () => {
act(() => {
fireEvent.click(saveButton);
});
// updateFavorite called with the favorite id and new title
expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub Updated' }),
);
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub Updated' }),
);
});
});
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
@@ -217,12 +228,14 @@ describe('FavoritesWidget', () => {
const deleteButtons = screen.getAllByRole('button', {
name: /favorites\.deleteButton/i,
});
await act(async () => {
act(() => {
fireEvent.click(deleteButtons[0]);
});
// deleteFavorite called with the correct id
expect(mockDelete).toHaveBeenCalledWith('fav-id-1');
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith('fav-id-1');
});
// The deleted favorite is removed from the list
await waitFor(() => {