Skip to content
Merged
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
26 changes: 20 additions & 6 deletions frontend/src/pages/Findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -639,16 +639,30 @@ export default function Findings() {
overscan: 6,
})

// Scroll selected finding into view when it changes
// Keep latest virtualRows/virtualizer available without making them
// reactive dependencies — they change on every filter/sort, but we only
// want to re-scroll when the *selection* actually changes.
const virtualRowsRef = useRef(virtualRows)
useEffect(() => {
if (!selectedFinding) return
const rowIdx = virtualRows.findIndex(
(row) => row.kind === 'finding' && row.finding.id === selectedFinding.id,
virtualRowsRef.current = virtualRows
})

const virtualizerRef = useRef(virtualizer)
useEffect(() => {
virtualizerRef.current = virtualizer
})

// Scroll selected finding into view when the selection changes
useEffect(() => {
if (!selectedFindingId) return
const rows = virtualRowsRef.current
const rowIdx = rows.findIndex(
(row) => row.kind === 'finding' && row.finding.id === selectedFindingId,
)
if (rowIdx !== -1) {
virtualizer.scrollToIndex(rowIdx, { align: 'auto', behavior: 'smooth' })
virtualizerRef.current.scrollToIndex(rowIdx, { align: 'auto', behavior: 'smooth' })
}
}, [selectedFindingId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedFindingId])
return (
<div className="min-h-screen bg-charcoal-dark text-silver px-4 py-6 md:px-8 md:py-10">
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-8">
Expand Down
27 changes: 27 additions & 0 deletions frontend/testing/unit/pages/Findings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,30 @@ describe('Findings — virtualizer scrolling', () => {
expect(mockScrollToIndex).not.toHaveBeenCalled()
})
})

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' })
})
Loading