diff --git a/web/src/components/BrowseList.test.tsx b/web/src/components/BrowseList.test.tsx index 2188c53..d3690ef 100644 --- a/web/src/components/BrowseList.test.tsx +++ b/web/src/components/BrowseList.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { CommandsContext } from "../hooks/useCommands"; import { makeBrowseResponse, makeSummary, mockFetch } from "../test-utils"; import { BrowseList } from "./BrowseList"; @@ -86,6 +86,83 @@ describe("BrowseList", () => { await waitFor(() => expect(screen.getByTestId("detail")).toBeInTheDocument()); }); + it("starts background polling after the initial load", async () => { + let pollCallback: (() => void) | null = null; + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((( + fn: TimerHandler, + delay?: number, + ) => { + if (delay === 30_000) pollCallback = fn as () => void; + return 1 as ReturnType; + }) as typeof setInterval); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {}); + cleanupFetch = mockFetch(() => + makeBrowseResponse([makeSummary({ id: "1", title: "First" }), makeSummary({ id: "2", title: "Second" })]), + ); + + try { + renderBrowse(); + await waitFor(() => expect(screen.getAllByText("First").length).toBeGreaterThan(0)); + await waitFor(() => expect(pollCallback).not.toBeNull()); + } finally { + hiddenSpy.mockRestore(); + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + + it("preserves the selected item when polling reorders results", async () => { + let fetchCount = 0; + let pollCallback: (() => void) | null = null; + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((( + fn: TimerHandler, + delay?: number, + ) => { + if (delay === 30_000) pollCallback = fn as () => void; + return 1 as ReturnType; + }) as typeof setInterval); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {}); + cleanupFetch = mockFetch(() => { + fetchCount += 1; + return fetchCount === 1 + ? makeBrowseResponse([ + makeSummary({ id: "1", title: "Alpha" }), + makeSummary({ id: "2", title: "Bravo" }), + makeSummary({ id: "3", title: "Charlie" }), + ]) + : makeBrowseResponse([ + makeSummary({ id: "3", title: "Charlie" }), + makeSummary({ id: "1", title: "Alpha" }), + makeSummary({ id: "2", title: "Bravo" }), + ]); + }); + + try { + renderBrowse(); + await waitFor(() => expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0)); + await waitFor(() => expect(pollCallback).not.toBeNull()); + + fireEvent.keyDown(window, { key: "j" }); + fireEvent.keyDown(window, { key: "j" }); + expect(screen.getAllByRole("row")[2]).toHaveAttribute("data-selected", "true"); + + await act(async () => { + pollCallback?.(); + }); + await waitFor(() => expect(fetchCount).toBe(2)); + + const selectedRow = screen.getAllByRole("row")[3]; + expect(selectedRow).toHaveAttribute("data-selected", "true"); + expect(selectedRow).toHaveTextContent("Bravo"); + } finally { + hiddenSpy.mockRestore(); + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + it("c opens WantedForm", async () => { cleanupFetch = mockFetch(() => makeBrowseResponse([makeSummary()])); renderBrowse(); diff --git a/web/src/components/BrowseList.tsx b/web/src/components/BrowseList.tsx index 9d73c20..b87fc07 100644 --- a/web/src/components/BrowseList.tsx +++ b/web/src/components/BrowseList.tsx @@ -6,6 +6,7 @@ import { consumePrefetch } from "../api/prefetch"; import type { PendingItemSummary, WantedSummary } from "../api/types"; import { useFilterParams } from "../hooks/useFilterParams"; import styles from "./BrowseList.module.css"; +import { findSelectedIndex, resolveSelectedIdAfterRefresh } from "./browseSelection"; import { EmptyState } from "./EmptyState"; import { FilterBar } from "./FilterBar"; import { PriorityBadge } from "./PriorityBadge"; @@ -22,14 +23,16 @@ export function BrowseList() { const [warning, setWarning] = useState(""); const [showForm, setShowForm] = useState(false); const [showInferForm, setShowInferForm] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(-1); - const selectedIndexRef = useRef(-1); + const [pollingEnabled, setPollingEnabled] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const selectedIdRef = useRef(null); const searchRef = useRef(null); const hasLoadedRef = useRef(false); + const selectedIndex = findSelectedIndex(items, selectedId); - const setSelection = useCallback((next: number) => { - selectedIndexRef.current = next; - setSelectedIndex(next); + const setSelection = useCallback((next: string | null) => { + selectedIdRef.current = next; + setSelectedId(next); }, []); const load = useCallback(async () => { @@ -42,8 +45,9 @@ export function BrowseList() { const resp = (prefetched && (await prefetched)) || (await browse(filter)); setItems(resp.items); if (resp.warning) setWarning(resp.warning); - setSelection(-1); + setSelection(null); hasLoadedRef.current = true; + setPollingEnabled(true); } catch (e) { const msg = e instanceof Error ? e.message : "Failed to load"; setError(msg); @@ -59,18 +63,18 @@ export function BrowseList() { // Silent background poll — no loading spinner, no error toasts. useEffect(() => { - if (!hasLoadedRef.current) return; + if (!pollingEnabled) return; const id = setInterval(() => { if (document.hidden) return; browse(filter) .then((resp) => { setItems(resp.items); - setSelection(-1); + setSelection(resolveSelectedIdAfterRefresh(selectedIdRef.current, resp.items)); }) .catch(() => {}); }, 30_000); return () => clearInterval(id); - }, [filter, setSelection]); + }, [filter, pollingEnabled, setSelection]); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -82,17 +86,19 @@ export function BrowseList() { case "j": { e.preventDefault(); if (items.length === 0) break; - setSelection(Math.min(selectedIndexRef.current + 1, items.length - 1)); + const nextIndex = Math.min(findSelectedIndex(items, selectedIdRef.current) + 1, items.length - 1); + setSelection(items[nextIndex]?.id ?? null); break; } case "k": { e.preventDefault(); if (items.length === 0) break; - setSelection(Math.max(selectedIndexRef.current - 1, 0)); + const nextIndex = Math.max(findSelectedIndex(items, selectedIdRef.current) - 1, 0); + setSelection(items[nextIndex]?.id ?? null); break; } case "Enter": { - const index = selectedIndexRef.current; + const index = findSelectedIndex(items, selectedIdRef.current); if (index >= 0 && index < items.length) { startTransition(() => navigate(`/wanted/${items[index].id}`)); } diff --git a/web/src/components/browseSelection.test.ts b/web/src/components/browseSelection.test.ts new file mode 100644 index 0000000..3ebdeef --- /dev/null +++ b/web/src/components/browseSelection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { makeSummary } from "../test-utils"; +import { findSelectedIndex, resolveSelectedIdAfterRefresh } from "./browseSelection"; + +describe("browseSelection", () => { + it("finds the selected item by id", () => { + const items = [makeSummary({ id: "1" }), makeSummary({ id: "2" }), makeSummary({ id: "3" })]; + + expect(findSelectedIndex(items, "2")).toBe(1); + }); + + it("preserves the selected id when the item still exists after refresh", () => { + const nextItems = [makeSummary({ id: "3" }), makeSummary({ id: "1" }), makeSummary({ id: "2" })]; + + expect(resolveSelectedIdAfterRefresh("2", nextItems)).toBe("2"); + }); + + it("clears the selected id when the item disappears after refresh", () => { + const nextItems = [makeSummary({ id: "1" }), makeSummary({ id: "3" })]; + + expect(resolveSelectedIdAfterRefresh("2", nextItems)).toBeNull(); + }); +}); diff --git a/web/src/components/browseSelection.ts b/web/src/components/browseSelection.ts new file mode 100644 index 0000000..565352d --- /dev/null +++ b/web/src/components/browseSelection.ts @@ -0,0 +1,11 @@ +import type { WantedSummary } from "../api/types"; + +export function findSelectedIndex(items: WantedSummary[], selectedId: string | null): number { + if (!selectedId) return -1; + return items.findIndex((item) => item.id === selectedId); +} + +export function resolveSelectedIdAfterRefresh(selectedId: string | null, nextItems: WantedSummary[]): string | null { + if (!selectedId) return null; + return nextItems.some((item) => item.id === selectedId) ? selectedId : null; +}