Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/src/pages/Findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions frontend/testing/unit/pages/Findings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Findings />)
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(<Findings />)
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(<Findings />)
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' }),
Expand Down
Loading