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
81 changes: 79 additions & 2 deletions web/src/components/BrowseList.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof setInterval>;
}) 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<typeof setInterval>;
}) 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();
Expand Down
30 changes: 18 additions & 12 deletions web/src/components/BrowseList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | null>(null);
const selectedIdRef = useRef<string | null>(null);
const searchRef = useRef<HTMLInputElement>(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 () => {
Expand All @@ -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);
Expand All @@ -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) => {
Expand All @@ -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}`));
}
Expand Down
23 changes: 23 additions & 0 deletions web/src/components/browseSelection.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
11 changes: 11 additions & 0 deletions web/src/components/browseSelection.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading