diff --git a/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx b/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx index 01d4a77e17..52416d9ef5 100644 --- a/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx +++ b/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -1033,6 +1033,160 @@ describe("GraphExplorer", () => { }); }); +// ── Live search: auto re-run on filter/query change ────────────────────────── +describe("GraphExplorer live search", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + // ── (a) Toggling a type filter with an existing query auto-re-runs search ── + test("toggling a node-type filter with an existing query auto-re-runs search", async () => { + const fetchMock = makeRoutedFetch([ + NODE_TYPES_ROUTE, + { match: "/graph/nodes/search", ok: true, status: 200, body: MOCK_SEARCH_RESULTS }, + ]); + global.fetch = fetchMock; + + render(); + await waitFor(() => expect(urlsFor(fetchMock, "/graph/node-types")).toHaveLength(1)); + + await userEvent.type(screen.getByTestId("search-input"), "processData"); + await userEvent.click(screen.getByTestId("search-button")); + await waitFor(() => expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(1)); + + // Toggling a filter with a query already present should re-fire on its own — + // no second click on the Search button. + await userEvent.click(screen.getByTestId("node-type-filter-button")); + await waitFor(() => screen.getByTestId("node-type-filter-option-Function")); + await userEvent.click(screen.getByTestId("node-type-filter-option-Function")); + + await waitFor(() => expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(2)); + expect(urlsFor(fetchMock, "/graph/nodes/search")[1]).toContain( + `types=${encodeURIComponent("Concept,Function")}`, + ); + }); + + // ── (b) Typing debounces before firing ──────────────────────────────────── + test("typing in the query box debounces before firing", async () => { + vi.useFakeTimers(); + try { + const fetchMock = makeRoutedFetch([ + NODE_TYPES_ROUTE, + { match: "/graph/nodes/search", ok: true, status: 200, body: MOCK_SEARCH_RESULTS }, + ]); + global.fetch = fetchMock; + + render(); + const input = screen.getByTestId("search-input"); + + fireEvent.change(input, { target: { value: "p" } }); + await act(async () => { + vi.advanceTimersByTime(100); + }); + fireEvent.change(input, { target: { value: "pr" } }); + await act(async () => { + vi.advanceTimersByTime(100); + }); + fireEvent.change(input, { target: { value: "pro" } }); + + // Still inside the quiet window — no request fired yet. + expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(0); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + // Only the settled value fires, and only once. + expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(1); + expect(urlsFor(fetchMock, "/graph/nodes/search")[0]).toContain("q=pro"); + } finally { + vi.useRealTimers(); + } + }); + + // ── (c) Empty query doesn't fetch on filter change ──────────────────────── + test("an empty query does not trigger a fetch when toggling a filter", async () => { + const fetchMock = makeRoutedFetch([NODE_TYPES_ROUTE]); + global.fetch = fetchMock; + + render(); + await waitFor(() => expect(urlsFor(fetchMock, "/graph/node-types")).toHaveLength(1)); + + await userEvent.click(screen.getByTestId("node-type-filter-button")); + await waitFor(() => screen.getByTestId("node-type-filter-option-Function")); + await userEvent.click(screen.getByTestId("node-type-filter-option-Function")); + await userEvent.keyboard("{Escape}"); + + // No query text was ever entered — the filter click must not fetch. + expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(0); + }); + + // ── (d) Stale/out-of-order responses don't clobber newer results ───────── + test("a slow, superseded search response does not overwrite a newer one", async () => { + const respond = (body: unknown) => ({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(""), + }); + + let releaseFirst: (() => void) | undefined; + let searchCallCount = 0; + + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes("/graph/node-types")) return Promise.resolve(respond(MOCK_NODE_TYPES)); + if (url.includes("/graph/nodes/search")) { + searchCallCount += 1; + if (searchCallCount === 1) { + // The first (stale) request hangs until explicitly released. + return new Promise((resolve) => { + releaseFirst = () => + resolve( + respond({ + results: [ + { ref_id: "stale-1", node_type: "Concept", name: "StaleResult", description: "" }, + ], + }), + ); + }); + } + // Every later request resolves immediately with fresh results. + return Promise.resolve( + respond({ + results: [ + { ref_id: "fresh-1", node_type: "Concept", name: "FreshResult", description: "" }, + ], + }), + ); + } + return Promise.resolve(respond({})); + }); + + render(); + await userEvent.type(screen.getByTestId("search-input"), "processData"); + await userEvent.click(screen.getByTestId("search-button")); + await waitFor(() => expect(searchCallCount).toBe(1)); + + // Toggling a filter while the first request is still in flight fires a + // second, faster request that should win. + await userEvent.click(screen.getByTestId("node-type-filter-button")); + await waitFor(() => screen.getByTestId("node-type-filter-option-Function")); + await userEvent.click(screen.getByTestId("node-type-filter-option-Function")); + + await waitFor(() => expect(screen.getByText("FreshResult")).toBeInTheDocument()); + + // Now let the stale first request resolve — it must not clobber the fresh + // results that are already on screen. + releaseFirst?.(); + await waitFor(() => expect(searchCallCount).toBeGreaterThanOrEqual(2)); + await new Promise((r) => setTimeout(r, 0)); + + expect(screen.getByText("FreshResult")).toBeInTheDocument(); + expect(screen.queryByText("StaleResult")).not.toBeInTheDocument(); + }); +}); + // ── Legal recursion mock branch ─────────────────────────────────────────────── describe("Legal recursion Cypher mock branch", () => { beforeEach(() => { diff --git a/src/components/graph-explorer/GraphExplorer.tsx b/src/components/graph-explorer/GraphExplorer.tsx index 9d01d1603c..ee5a7f1d7e 100644 --- a/src/components/graph-explorer/GraphExplorer.tsx +++ b/src/components/graph-explorer/GraphExplorer.tsx @@ -662,40 +662,96 @@ export function GraphExplorer({ workspaceSlug, initialRefId, initialCypher }: Gr }, []); // ── Semantic search (Jarvis hybrid keyword + vector) ────────────────────── + /** Guards against a slow search response overwriting a newer one. */ + const searchRequestRef = useRef(0); + const runSearch = useCallback(async () => { - if (!searchQuery.trim()) return; + const q = searchQuery.trim(); + if (!q) return; + const requestId = ++searchRequestRef.current; setSearchLoading(true); setSearchError(null); setSearchResults([]); setSearched(true); try { - const params = new URLSearchParams({ q: searchQuery.trim(), limit: "25" }); + const params = new URLSearchParams({ q, limit: "25" }); if (selectedTypes.length > 0) params.set("types", selectedTypes.join(",")); const res = await fetch( `/api/workspaces/${workspaceSlug}/graph/nodes/search?${params.toString()}` ); + // A newer search superseded this one while it was in flight — drop it. + if (requestId !== searchRequestRef.current) return; + if (!res.ok) { const data = await res.json().catch(() => ({})); + if (requestId !== searchRequestRef.current) return; setSearchError((data as { message?: string }).message || `Search failed (${res.status})`); return; } const data: GraphSearchResponse = await res.json(); + if (requestId !== searchRequestRef.current) return; setSearchResults(Array.isArray(data?.results) ? data.results : []); } catch (err) { + if (requestId !== searchRequestRef.current) return; setSearchError(err instanceof Error ? err.message : "Search failed"); } finally { - setSearchLoading(false); + if (requestId === searchRequestRef.current) setSearchLoading(false); } }, [searchQuery, selectedTypes, workspaceSlug]); + /** Pending debounced auto-search timer, cleared before any direct trigger. */ + const searchDebounceRef = useRef | null>(null); + /** Previous `selectedTypes` reference, used to tell a filter click apart from typing. */ + const prevSelectedTypesRef = useRef(selectedTypes); + + const clearPendingSearch = useCallback(() => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = null; + } + }, []); + + /** Manual trigger (Search button / Enter): cancels any pending debounce so it can't double-fire. */ + const triggerSearch = useCallback(() => { + clearPendingSearch(); + void runSearch(); + }, [clearPendingSearch, runSearch]); + + /** + * Live search: re-runs automatically whenever the type filter or the query + * text changes, as long as there's a query to search for. A filter toggle is + * a discrete click, so it re-runs immediately; free-typing is debounced so + * we don't fire a request per keystroke. + */ + useEffect(() => { + const typesChanged = prevSelectedTypesRef.current !== selectedTypes; + prevSelectedTypesRef.current = selectedTypes; + + clearPendingSearch(); + + if (!searchQuery.trim()) return; + + if (typesChanged) { + void runSearch(); + return; + } + + searchDebounceRef.current = setTimeout(() => { + searchDebounceRef.current = null; + void runSearch(); + }, 300); + + return () => clearPendingSearch(); + }, [selectedTypes, searchQuery, runSearch, clearPendingSearch]); + const typeFilterLabel = typeFilterLabelFor(selectedTypes); const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - runSearch(); + triggerSearch(); } }; @@ -774,7 +830,7 @@ export function GraphExplorer({ workspaceSlug, initialRefId, initialCypher }: Gr