feat(260723-lvg): add "Jetzt abrufen" manual poll button to tender radar
PollNowButton sits next to the settings gear in the tender-radar header, mirrors the DKV spinner/disabled UX, and bumps a refreshKey on success to refetch ResultsList without touching any filter. Failure surfaces an i18n error (de/en parity). pollNow() client hits POST /modules/tender-radar/poll-now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PollNowButton.test — RED-first (TDD) proof for Quick 260723-lvg Task 2.
|
||||||
|
* Mirrors the ResultsList.test.tsx mock-the-api-client-module convention:
|
||||||
|
* mock @/lib/tender-radar-api's pollNow, mock next-intl with a flat
|
||||||
|
* key -> copy lookup for the `page.*` keys this component uses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockPollNow = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/tender-radar-api', () => ({
|
||||||
|
pollNow: (...args: unknown[]) => mockPollNow(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'page.pollNow': 'Jetzt abrufen',
|
||||||
|
'page.pollNowTitle': 'Fällige Quellen jetzt abrufen',
|
||||||
|
'page.polling': 'Wird abgerufen…',
|
||||||
|
'page.pollError': 'Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen Ihnen die nötigen Rechte.',
|
||||||
|
'page.pollErrorDismiss': 'Schließen',
|
||||||
|
};
|
||||||
|
return translations[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockPollNow.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PollNowButton', () => {
|
||||||
|
it('renders the "Jetzt abrufen" label', async () => {
|
||||||
|
const { PollNowButton } = await import('./PollNowButton');
|
||||||
|
render(<PollNowButton />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Jetzt abrufen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the button calls pollNow once and calls onPolled on success', async () => {
|
||||||
|
mockPollNow.mockResolvedValue({ ok: true });
|
||||||
|
const onPolled = vi.fn();
|
||||||
|
|
||||||
|
const { PollNowButton } = await import('./PollNowButton');
|
||||||
|
render(<PollNowButton onPolled={onPolled} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Jetzt abrufen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onPolled).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(mockPollNow).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the spinner/polling copy and disables the button while the poll is pending', async () => {
|
||||||
|
let resolvePoll: (value: { ok: boolean }) => void = () => {};
|
||||||
|
mockPollNow.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePoll = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onPolled = vi.fn();
|
||||||
|
|
||||||
|
const { PollNowButton } = await import('./PollNowButton');
|
||||||
|
render(<PollNowButton onPolled={onPolled} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Jetzt abrufen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /Wird abgerufen…/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
resolvePoll({ ok: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Jetzt abrufen' })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on rejection shows the pollError copy and never calls onPolled', async () => {
|
||||||
|
mockPollNow.mockRejectedValue(new Error('403'));
|
||||||
|
const onPolled = vi.fn();
|
||||||
|
|
||||||
|
const { PollNowButton } = await import('./PollNowButton');
|
||||||
|
render(<PollNowButton onPolled={onPolled} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Jetzt abrufen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen Ihnen die nötigen Rechte.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(onPolled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { pollNow } from '@/lib/tender-radar-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline spinner SVG — copied from the DKV "Jetzt prüfen" pattern
|
||||||
|
* (dkv-fleet/page.tsx), 20×20, animate-spin, stroke="currentColor".
|
||||||
|
*/
|
||||||
|
function SpinnerIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-4 w-4 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PollNowButtonProps {
|
||||||
|
/** Called after a successful poll — drives the parent's refreshKey bump. */
|
||||||
|
onPolled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PollNowButton — "Jetzt abrufen" manual poll trigger for the
|
||||||
|
* Ausschreibungs-Radar header (Quick 260723-lvg). Self-contained so it can
|
||||||
|
* be tested in isolation (same convention as CoverageBanner/ResultsList/
|
||||||
|
* SavedSearchBar). Mirrors the DKV "Jetzt prüfen" spinner/disabled UX.
|
||||||
|
*
|
||||||
|
* Semantic honesty (see PLAN.md): the button triggers fällige Quellen jetzt
|
||||||
|
* abrufen, NOT a forced re-download — `pollDueSources()` honors the
|
||||||
|
* existing day-cursor server-side.
|
||||||
|
*/
|
||||||
|
export function PollNowButton({ onPolled }: PollNowButtonProps) {
|
||||||
|
const t = useTranslations('tenderRadar');
|
||||||
|
const [isPolling, setIsPolling] = useState(false);
|
||||||
|
const [pollError, setPollError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handlePoll = async () => {
|
||||||
|
setIsPolling(true);
|
||||||
|
setPollError(null);
|
||||||
|
try {
|
||||||
|
await pollNow();
|
||||||
|
onPolled?.();
|
||||||
|
} catch {
|
||||||
|
setPollError(t('page.pollError'));
|
||||||
|
} finally {
|
||||||
|
setIsPolling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePoll}
|
||||||
|
disabled={isPolling}
|
||||||
|
title={t('page.pollNowTitle')}
|
||||||
|
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
|
||||||
|
isPolling ? ' opacity-80 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPolling ? (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon />
|
||||||
|
{t('page.polling')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('page.pollNow')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pollError && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||||
|
<span>{pollError}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPollError(null)}
|
||||||
|
className="font-medium hover:opacity-70"
|
||||||
|
aria-label={t('page.pollErrorDismiss')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ import {
|
|||||||
*
|
*
|
||||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||||
* tenderRadar i18n namespace.
|
* tenderRadar i18n namespace.
|
||||||
|
*
|
||||||
|
* Quick 260723-lvg: accepts an optional `refreshKey` prop — incrementing it
|
||||||
|
* (driven by PollNowButton.onPolled in page.tsx) re-runs `load()` and
|
||||||
|
* refetches the list without any URL/filter change, same
|
||||||
|
* parent-increments-refreshKey pattern DKV uses for InvoiceHistoryTable
|
||||||
|
* (STATE decision 07-05).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [
|
const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [
|
||||||
@@ -62,7 +68,7 @@ function formatValue(value: string | null, noValueLabel: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsList() {
|
export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
|
||||||
const t = useTranslations('tenderRadar');
|
const t = useTranslations('tenderRadar');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -121,7 +127,7 @@ export function ResultsList() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [paramsKey]);
|
}, [paramsKey, refreshKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { CoverageBanner } from './components/CoverageBanner';
|
import { CoverageBanner } from './components/CoverageBanner';
|
||||||
import { FilterPanel } from './components/FilterPanel';
|
import { FilterPanel } from './components/FilterPanel';
|
||||||
|
import { PollNowButton } from './components/PollNowButton';
|
||||||
import { ResultsList } from './components/ResultsList';
|
import { ResultsList } from './components/ResultsList';
|
||||||
import { SavedSearchBar } from './components/SavedSearchBar';
|
import { SavedSearchBar } from './components/SavedSearchBar';
|
||||||
import { TenderDetail } from './components/TenderDetail';
|
import { TenderDetail } from './components/TenderDetail';
|
||||||
@@ -38,12 +39,18 @@ import { TenderDetail } from './components/TenderDetail';
|
|||||||
*
|
*
|
||||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||||
* tenderRadar i18n namespace — no more hardcoded German literals.
|
* tenderRadar i18n namespace — no more hardcoded German literals.
|
||||||
|
*
|
||||||
|
* Quick 260723-lvg: PollNowButton sits next to the settings gear and
|
||||||
|
* bumps `refreshKey` on a successful poll, driving a ResultsList refetch —
|
||||||
|
* same parent-increments-refreshKey pattern DKV uses for
|
||||||
|
* InvoiceHistoryTable (STATE decision 07-05).
|
||||||
*/
|
*/
|
||||||
function TenderRadarContent() {
|
function TenderRadarContent() {
|
||||||
const t = useTranslations('tenderRadar');
|
const t = useTranslations('tenderRadar');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const tenderId = searchParams.get('tender');
|
const tenderId = searchParams.get('tender');
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
const closeDetail = () => {
|
const closeDetail = () => {
|
||||||
const next = new URLSearchParams(searchParams.toString());
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
@@ -58,6 +65,8 @@ function TenderRadarContent() {
|
|||||||
{t('page.title')}
|
{t('page.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/*
|
{/*
|
||||||
* Einstellungen-Link. Absolute literal href (NOT the dynamic
|
* Einstellungen-Link. Absolute literal href (NOT the dynamic
|
||||||
* [category]/[moduleSlug] basePath): the tender-radar settings page
|
* [category]/[moduleSlug] basePath): the tender-radar settings page
|
||||||
@@ -76,12 +85,15 @@ function TenderRadarContent() {
|
|||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CoverageBanner />
|
<CoverageBanner />
|
||||||
<SavedSearchBar />
|
<SavedSearchBar />
|
||||||
<FilterPanel />
|
<FilterPanel />
|
||||||
<ResultsList />
|
<ResultsList refreshKey={refreshKey} />
|
||||||
|
|
||||||
{tenderId && <TenderDetail tenderId={tenderId} onClose={closeDetail} />}
|
{tenderId && <TenderDetail tenderId={tenderId} onClose={closeDetail} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,6 +150,23 @@ export async function listTenders(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate manual poll of all DUE tender sources (Quick
|
||||||
|
* 260723-lvg — "Jetzt abrufen"). Mirrors the DKV `checkNow` client shape.
|
||||||
|
* This is honest "fällige Quellen jetzt abrufen": 'day'-granularity
|
||||||
|
* sources still honor their nextDayToFetch cursor server-side — this is
|
||||||
|
* NOT a forced re-download.
|
||||||
|
* POST /modules/tender-radar/poll-now
|
||||||
|
*/
|
||||||
|
export async function pollNow(): Promise<{ ok: boolean }> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/tender-radar/poll-now`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to trigger tender poll');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the active-tender distribution by sourcePortal (D-12, UI-05) —
|
* Fetch the active-tender distribution by sourcePortal (D-12, UI-05) —
|
||||||
* feeds CoverageBanner so a thin/single-source result list is not
|
* feeds CoverageBanner so a thin/single-source result list is not
|
||||||
|
|||||||
@@ -576,7 +576,12 @@
|
|||||||
"page": {
|
"page": {
|
||||||
"title": "Ausschreibungs-Radar",
|
"title": "Ausschreibungs-Radar",
|
||||||
"loading": "Lädt…",
|
"loading": "Lädt…",
|
||||||
"settingsTitle": "Einstellungen"
|
"settingsTitle": "Einstellungen",
|
||||||
|
"pollNow": "Jetzt abrufen",
|
||||||
|
"pollNowTitle": "Fällige Quellen jetzt abrufen",
|
||||||
|
"polling": "Wird abgerufen…",
|
||||||
|
"pollError": "Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen Ihnen die nötigen Rechte.",
|
||||||
|
"pollErrorDismiss": "Schließen"
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
"colTitle": "Titel",
|
"colTitle": "Titel",
|
||||||
|
|||||||
@@ -576,7 +576,12 @@
|
|||||||
"page": {
|
"page": {
|
||||||
"title": "Tender Radar",
|
"title": "Tender Radar",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"settingsTitle": "Settings"
|
"settingsTitle": "Settings",
|
||||||
|
"pollNow": "Fetch now",
|
||||||
|
"pollNowTitle": "Fetch due sources now",
|
||||||
|
"polling": "Fetching…",
|
||||||
|
"pollError": "The fetch could not be triggered. You may not have the required permissions.",
|
||||||
|
"pollErrorDismiss": "Dismiss"
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
"colTitle": "Title",
|
"colTitle": "Title",
|
||||||
|
|||||||
Reference in New Issue
Block a user