feat(11-05): read/favorite triage toggles + Merklisten-Filter in the UI
Adds fetchTriage()/setTriage() to tender-radar-api.ts (plain fetch, consistent with the existing client). ResultsList batch-fetches the current user's triage state for the visible ids and merges it into a local per-tenderId map; a failed triage fetch never blocks rendering the list itself. Each row gets a Gelesen/Ungelesen and a Favorit toggle (optimistic update with revert-on-failure, event.stopPropagation() so the row's own click-to-open-detail doesn't fire); read rows render dimmed. FilterPanel gains a "Nur Favoriten/Merkliste" checkbox writing favOnly into the URL, which ResultsList already forwards generically to the backend. ResultsList.test.tsx extended (Rule 3 — required to keep the component test green with the new triage batch call) with coverage for batch-merge, both toggles, optimistic revert, and graceful degradation when the triage fetch fails. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,10 @@ export function FilterPanel() {
|
||||
// includeNullValue defaults to true (D-05 Pflichtkriterium) — only an
|
||||
// explicit "false" excludes rows without an estimatedValue.
|
||||
const includeNullValue = searchParams.get('includeNullValue') !== 'false';
|
||||
// Merklisten-Filter (UI-04, D-10). Default off — only an explicit "true"
|
||||
// restricts the list to this user's favorited tenders (backend derives
|
||||
// the favorite tenderIds from the auth context, never from this param).
|
||||
const favOnly = searchParams.get('favOnly') === 'true';
|
||||
|
||||
const setParam = (key: string, value: string | null) => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
@@ -220,6 +224,19 @@ export function FilterPanel() {
|
||||
Nur noch offene
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<input
|
||||
id="tr-filter-fav-only"
|
||||
type="checkbox"
|
||||
checked={favOnly}
|
||||
onChange={(e) => setParam('favOnly', e.target.checked ? 'true' : null)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="tr-filter-fav-only" className="text-sm text-foreground">
|
||||
Nur Favoriten/Merkliste
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05).
|
||||
* Mirrors the SourceConfigForm.test.tsx pattern: mock the api client
|
||||
* module, render, assert via waitFor. next/navigation is mocked because
|
||||
* ResultsList reads/writes URL searchParams for filter + sort state
|
||||
* (Research: URL-Param-getriebener Client).
|
||||
* ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05)
|
||||
* plus Plan 11-05 (UI-03/04: gelesen/ungelesen + Favorit toggles, batch
|
||||
* triage merge). Mirrors the SourceConfigForm.test.tsx pattern: mock the
|
||||
* api client module, render, assert via waitFor. next/navigation is mocked
|
||||
* because ResultsList reads/writes URL searchParams for filter + sort
|
||||
* state (Research: URL-Param-getriebener Client).
|
||||
*/
|
||||
|
||||
const mockListTenders = vi.fn();
|
||||
const mockFetchTriage = vi.fn();
|
||||
const mockSetTriage = vi.fn();
|
||||
|
||||
vi.mock('@/lib/tender-radar-api', () => ({
|
||||
listTenders: (...args: unknown[]) => mockListTenders(...args),
|
||||
fetchTriage: (...args: unknown[]) => mockFetchTriage(...args),
|
||||
setTriage: (...args: unknown[]) => mockSetTriage(...args),
|
||||
}));
|
||||
|
||||
const mockReplace = vi.fn();
|
||||
@@ -26,6 +31,8 @@ vi.mock('next/navigation', () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockListTenders.mockReset();
|
||||
mockFetchTriage.mockReset();
|
||||
mockSetTriage.mockReset();
|
||||
mockReplace.mockReset();
|
||||
mockSearchParams = new URLSearchParams();
|
||||
vi.restoreAllMocks();
|
||||
@@ -48,6 +55,7 @@ describe('ResultsList', () => {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
@@ -75,6 +83,7 @@ describe('ResultsList', () => {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
@@ -86,6 +95,7 @@ describe('ResultsList', () => {
|
||||
|
||||
it('renders an empty-state message when listTenders returns no items', async () => {
|
||||
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
@@ -95,3 +105,110 @@ describe('ResultsList', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResultsList — triage (UI-03/04, Plan 11-05: batch-merge + read/favorite toggles)', () => {
|
||||
const oneItem = {
|
||||
items: [
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Sanierung Rathaus',
|
||||
buyerName: 'Stadt Musterhausen',
|
||||
deadlineAt: '2026-09-01T00:00:00.000Z',
|
||||
estimatedValue: '150000.00',
|
||||
publishedAt: '2026-07-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
it('batch-fetches triage for the visible ids and merges the returned isRead/isFavorite state in', async () => {
|
||||
mockListTenders.mockResolvedValue(oneItem);
|
||||
mockFetchTriage.mockResolvedValue([
|
||||
{ tenderId: 't1', isRead: true, isFavorite: true },
|
||||
]);
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTriage).toHaveBeenCalledWith(['t1']);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Als ungelesen markieren' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the read toggle calls setTriage(tenderId, isRead: true) and flips the button label optimistically', async () => {
|
||||
mockListTenders.mockResolvedValue(oneItem);
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
mockSetTriage.mockResolvedValue({ tenderId: 't1', isRead: true, isFavorite: false });
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
|
||||
const readButton = await screen.findByRole('button', {
|
||||
name: 'Als gelesen markieren',
|
||||
});
|
||||
fireEvent.click(readButton);
|
||||
|
||||
expect(mockSetTriage).toHaveBeenCalledWith({ tenderId: 't1', isRead: true });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Als ungelesen markieren' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking the favorite toggle calls setTriage(tenderId, isFavorite: true) and does not trigger the row click (openDetail)', async () => {
|
||||
mockListTenders.mockResolvedValue(oneItem);
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
mockSetTriage.mockResolvedValue({ tenderId: 't1', isRead: false, isFavorite: true });
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
|
||||
const favButton = await screen.findByRole('button', { name: 'Favorit setzen' });
|
||||
fireEvent.click(favButton);
|
||||
|
||||
expect(mockSetTriage).toHaveBeenCalledWith({ tenderId: 't1', isFavorite: true });
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('reverts the optimistic update when setTriage rejects', async () => {
|
||||
mockListTenders.mockResolvedValue(oneItem);
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
mockSetTriage.mockRejectedValue(new Error('network error'));
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
|
||||
const readButton = await screen.findByRole('button', {
|
||||
name: 'Als gelesen markieren',
|
||||
});
|
||||
fireEvent.click(readButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Als gelesen markieren' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('a failed batch triage fetch does not block rendering the tender list', async () => {
|
||||
mockListTenders.mockResolvedValue(oneItem);
|
||||
mockFetchTriage.mockRejectedValue(new Error('network error'));
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
render(<ResultsList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sanierung Rathaus')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { listTenders, type Tender } from '@/lib/tender-radar-api';
|
||||
import {
|
||||
fetchTriage,
|
||||
listTenders,
|
||||
setTriage,
|
||||
type Tender,
|
||||
} from '@/lib/tender-radar-api';
|
||||
|
||||
/**
|
||||
* ResultsList — the Trefferliste replacing the Phase 10 module placeholder
|
||||
@@ -24,6 +29,12 @@ const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; label: stri
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
|
||||
/** Per-tenderId triage state merged in from the batch GET /triage fetch. */
|
||||
interface TriageState {
|
||||
isRead: boolean;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '–';
|
||||
const d = new Date(value);
|
||||
@@ -57,6 +68,12 @@ export function ResultsList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// UI-03/04: per-tenderId triage overlay (gelesen/ungelesen, Favorit),
|
||||
// merged in from a batch GET /triage fetch of the visible ids. Keyed by
|
||||
// tenderId, not persisted beyond this component's lifetime — always
|
||||
// re-fetched per page/filter change (D-11: per-user, never trusted from
|
||||
// any other source).
|
||||
const [triage, setTriageMap] = useState<Record<string, TriageState>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -68,6 +85,29 @@ export function ResultsList() {
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
|
||||
// Batch-merge this user's triage state for the visible ids (UI-03/04,
|
||||
// key_link: "GET /triage batch → merge in ResultsList"). Triage is a
|
||||
// supplementary per-user overlay — a failed batch-load must not block
|
||||
// rendering the tender list itself.
|
||||
const ids = data.items.map((t) => t.id);
|
||||
try {
|
||||
const rows = await fetchTriage(ids);
|
||||
if (Array.isArray(rows)) {
|
||||
setTriageMap((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const row of rows) {
|
||||
next[row.tenderId] = {
|
||||
isRead: row.isRead,
|
||||
isFavorite: row.isFavorite,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Swallow — the list itself is not blocked by a triage load failure.
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
@@ -107,6 +147,45 @@ export function ResultsList() {
|
||||
router.replace(`?${next.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gelesen/Ungelesen-Toggle (D-09, UI-03). Optimistic update — reverts on
|
||||
* a failed PUT so the UI never claims a state the backend didn't accept.
|
||||
* `stopPropagation` prevents the row's own onClick (openDetail) firing.
|
||||
*/
|
||||
const toggleRead = async (e: React.MouseEvent, tender: Tender) => {
|
||||
e.stopPropagation();
|
||||
const previous = triage[tender.id] ?? { isRead: false, isFavorite: false };
|
||||
const nextRead = !previous.isRead;
|
||||
setTriageMap((prev) => ({
|
||||
...prev,
|
||||
[tender.id]: { ...previous, isRead: nextRead },
|
||||
}));
|
||||
try {
|
||||
await setTriage({ tenderId: tender.id, isRead: nextRead });
|
||||
} catch {
|
||||
setTriageMap((prev) => ({ ...prev, [tender.id]: previous }));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Favorit-Toggle (D-10, UI-04). Same optimistic-with-revert pattern as
|
||||
* toggleRead.
|
||||
*/
|
||||
const toggleFavorite = async (e: React.MouseEvent, tender: Tender) => {
|
||||
e.stopPropagation();
|
||||
const previous = triage[tender.id] ?? { isRead: false, isFavorite: false };
|
||||
const nextFavorite = !previous.isFavorite;
|
||||
setTriageMap((prev) => ({
|
||||
...prev,
|
||||
[tender.id]: { ...previous, isFavorite: nextFavorite },
|
||||
}));
|
||||
try {
|
||||
await setTriage({ tenderId: tender.id, isFavorite: nextFavorite });
|
||||
} catch {
|
||||
setTriageMap((prev) => ({ ...prev, [tender.id]: previous }));
|
||||
}
|
||||
};
|
||||
|
||||
const limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT);
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
@@ -132,13 +211,14 @@ export function ResultsList() {
|
||||
{currentSort === h.key ? ' ▾' : ''}
|
||||
</th>
|
||||
))}
|
||||
<th className={thStaticCls}>Triage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 rounded bg-muted animate-pulse" />
|
||||
</td>
|
||||
@@ -147,13 +227,13 @@ export function ResultsList() {
|
||||
))
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-sm text-destructive">
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center">
|
||||
<td colSpan={6} className="px-4 py-10 text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
Keine Ausschreibungen gefunden.
|
||||
</p>
|
||||
@@ -164,21 +244,73 @@ export function ResultsList() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((tender) => (
|
||||
<tr
|
||||
key={tender.id}
|
||||
onClick={() => openDetail(tender.id)}
|
||||
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{tender.title}
|
||||
</td>
|
||||
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
|
||||
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
|
||||
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
|
||||
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
items.map((tender) => {
|
||||
const rowTriage = triage[tender.id] ?? {
|
||||
isRead: false,
|
||||
isFavorite: false,
|
||||
};
|
||||
return (
|
||||
<tr
|
||||
key={tender.id}
|
||||
onClick={() => openDetail(tender.id)}
|
||||
className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors ${
|
||||
rowTriage.isRead ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{tender.title}
|
||||
</td>
|
||||
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
|
||||
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
|
||||
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
|
||||
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleRead(e, tender)}
|
||||
aria-pressed={rowTriage.isRead}
|
||||
aria-label={
|
||||
rowTriage.isRead
|
||||
? 'Als ungelesen markieren'
|
||||
: 'Als gelesen markieren'
|
||||
}
|
||||
title={
|
||||
rowTriage.isRead
|
||||
? 'Als ungelesen markieren'
|
||||
: 'Als gelesen markieren'
|
||||
}
|
||||
className="rounded border border-border px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{rowTriage.isRead ? 'Gelesen' : 'Ungelesen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleFavorite(e, tender)}
|
||||
aria-pressed={rowTriage.isFavorite}
|
||||
aria-label={
|
||||
rowTriage.isFavorite
|
||||
? 'Favorit entfernen'
|
||||
: 'Favorit setzen'
|
||||
}
|
||||
title={
|
||||
rowTriage.isFavorite
|
||||
? 'Favorit entfernen'
|
||||
: 'Favorit setzen'
|
||||
}
|
||||
className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${
|
||||
rowTriage.isFavorite
|
||||
? 'border-amber-400 text-amber-500'
|
||||
: 'border-border text-foreground'
|
||||
}`}
|
||||
>
|
||||
{rowTriage.isFavorite ? '★ Favorit' : '☆ Favorit'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -151,3 +151,56 @@ export async function getTender(id: string): Promise<Tender> {
|
||||
if (!res.ok) throw new Error('Failed to fetch tender detail');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single per-user triage row (Plan 11-05, UI-03/04). Per-user, NOT
|
||||
* tenant-wide (D-11) — the backend derives userId from the auth cookie,
|
||||
* this client never sends a userId.
|
||||
*/
|
||||
export interface TriageEntry {
|
||||
tenderId: string;
|
||||
isRead: boolean;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
/** Payload accepted by PUT /modules/tender-radar/triage. */
|
||||
export interface SetTriagePayload {
|
||||
tenderId: string;
|
||||
isRead?: boolean;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch this user's triage state (gelesen/ungelesen, Favorit) for a
|
||||
* set of tenderIds (Plan 11-05, UI-03/04) — used by ResultsList to merge
|
||||
* triage state into the visible page in one round-trip.
|
||||
* GET /modules/tender-radar/triage?ids=<csv>
|
||||
*/
|
||||
export async function fetchTriage(ids: string[]): Promise<TriageEntry[]> {
|
||||
if (!ids.length) return [];
|
||||
const params = new URLSearchParams({ ids: ids.join(',') });
|
||||
const res = await fetch(
|
||||
`${API_URL}/modules/tender-radar/triage?${params}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch tender triage');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert this user's triage state for one tender (Plan 11-05, UI-03/04).
|
||||
* Idempotent on the backend (@@unique([userId,tenderId])).
|
||||
* PUT /modules/tender-radar/triage
|
||||
*/
|
||||
export async function setTriage(
|
||||
payload: SetTriagePayload,
|
||||
): Promise<TriageEntry> {
|
||||
const res = await fetch(`${API_URL}/modules/tender-radar/triage`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save tender triage');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user