diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 61e6d44e4..39050aa03 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -602,6 +602,25 @@ export default function Findings() { } async function loadMore() { + if (loadingMore) return + setLoadingMore(true) + const nextPage = page + 1 + try { + const data = await getFindings(nextPage, perPage) + const rawFindings = data.findings || [] + const moreFindings = rawFindings.filter( + (finding) => typeof finding.id === 'string', + ) as Finding[] + if (rawFindings.length > 0) { + setFindings((prev) => [...prev, ...moreFindings]) + setPage(nextPage) + } + // Fix #1862: keep totalItems in sync with each /findings response so + // the "Load More" guard (findings.length < totalItems) stays accurate + // even when filters change the server-side total between pages. + setTotalItems(data.total ?? moreFindings.length) + } finally { + setLoadingMore(false) if (loadingMore) return setLoadingMore(true) const nextPage = page + 1 diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 596fce909..7b5b5563a 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -537,6 +537,100 @@ describe('Findings — virtualizer scrolling', () => { }) it('scrolls to the correct fresh index after sort order changes then selection changes', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'Finding Alpha', severity: 'critical', discovered_at: '2024-01-01T00:00:00Z' }), + makeFinding({ id: 'f2', title: 'Finding Beta', severity: 'high', discovered_at: '2024-01-03T00:00:00Z' }), + makeFinding({ id: 'f3', title: 'Finding Gamma', severity: 'medium', discovered_at: '2024-01-02T00:00:00Z' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + // Switch to "newest" sort — new order is Beta(0), Gamma(1), Alpha(2) + const selects = screen.getAllByRole('combobox') + const sortSelect = selects.find((s) => + Array.from(s.querySelectorAll('option')).some((o) => /Newest First/i.test(o.textContent || '')), + ) + await userEvent.selectOptions(sortSelect!, 'newest') + + mockScrollToIndex.mockClear() + + // Now select Gamma — should scroll to its *post-sort* index (1), not a stale pre-sort index + const gammaOption = await screen.findByRole('option', { name: /Finding Gamma/i }) + await userEvent.click(gammaOption) + + expect(mockScrollToIndex).toHaveBeenCalledWith(1, { align: 'auto', behavior: 'smooth' }) +}) + +describe('Findings — load more totalItems sync (#1862)', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('updates totalItems after each loadMore fetch so the button guard stays accurate', async () => { + // Initial load: 2 findings, server reports 10 total + const page1 = [ + makeFinding({ id: 'p1-f1', title: 'Page 1 Finding A' }), + makeFinding({ id: 'p1-f2', title: 'Page 1 Finding B' }), + ] + // loadMore call: 2 more findings, server now reports total=4 (filter narrowed) + const page2 = [ + makeFinding({ id: 'p2-f1', title: 'Page 2 Finding A' }), + makeFinding({ id: 'p2-f2', title: 'Page 2 Finding B' }), + ] + + vi.mocked(getFindings) + .mockResolvedValueOnce({ findings: page1, total: 10 }) + .mockResolvedValueOnce({ findings: page2, total: 4 }) + + render() + await waitFor(() => + expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument(), + ) + + // After initial load: 2 findings loaded, server total=10, button shows "Load More (2/10)" + const loadMoreBtn = screen.getByRole('button', { name: /Load More/i }) + expect(loadMoreBtn).toHaveTextContent('Load More (2/10)') + + // Click Load More — triggers second fetch (total updates to 4) + await userEvent.click(loadMoreBtn) + + // After loadMore: 4 findings loaded, totalItems updated to 4 → button hidden (4 >= 4) + await waitFor(() => + expect(screen.queryByRole('button', { name: /Load More/i })).not.toBeInTheDocument(), + ) + }) + + it('shows Load More button when loadMore response total exceeds current findings count', async () => { + const page1 = [ + makeFinding({ id: 'p1-f1', title: 'Page 1 Finding' }), + ] + const page2 = [ + makeFinding({ id: 'p2-f1', title: 'Page 2 Finding' }), + ] + + vi.mocked(getFindings) + .mockResolvedValueOnce({ findings: page1, total: 5 }) + .mockResolvedValueOnce({ findings: page2, total: 5 }) + + render() + await waitFor(() => + expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument(), + ) + + // Initial state: 1/5 loaded, button visible + expect(screen.getByRole('button', { name: /Load More \(1\/5\)/i })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /Load More/i })) + + // After loadMore: 2/5, totalItems stays 5, button still visible + await waitFor(() => + expect(screen.getByRole('button', { name: /Load More \(2\/5\)/i })).toBeInTheDocument(), + ) + }) +}) const findings = [ makeFinding({ id: 'f1', title: 'Finding Alpha', severity: 'critical', discovered_at: '2024-01-01T00:00:00Z' }), makeFinding({ id: 'f2', title: 'Finding Beta', severity: 'high', discovered_at: '2024-01-03T00:00:00Z' }),