diff --git a/apps/web/src/components/knowledge/LinkAutocomplete.tsx b/apps/web/src/components/knowledge/LinkAutocomplete.tsx index 7c75f3f..fe78b60 100644 --- a/apps/web/src/components/knowledge/LinkAutocomplete.tsx +++ b/apps/web/src/components/knowledge/LinkAutocomplete.tsx @@ -49,6 +49,7 @@ export function LinkAutocomplete({ const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); + const [searchError, setSearchError] = useState(null); const dropdownRef = useRef(null); const searchTimeoutRef = useRef(null); const abortControllerRef = useRef(null); @@ -88,6 +89,7 @@ export function LinkAutocomplete({ setResults(searchResults); setSelectedIndex(0); + setSearchError(null); } catch (error) { // Ignore aborted requests - a newer search has superseded this one if (error instanceof DOMException && error.name === "AbortError") { @@ -95,6 +97,7 @@ export function LinkAutocomplete({ } console.error("Failed to search entries:", error); setResults([]); + setSearchError("Search unavailable — please try again"); } finally { if (!signal.aborted) { setIsLoading(false); @@ -371,6 +374,8 @@ export function LinkAutocomplete({ > {isLoading ? (
Searching...
+ ) : searchError ? ( +
{searchError}
) : results.length === 0 ? (
{state.query ? "No entries found" : "Start typing to search..."} diff --git a/apps/web/src/components/knowledge/__tests__/LinkAutocomplete.test.tsx b/apps/web/src/components/knowledge/__tests__/LinkAutocomplete.test.tsx index 5729c90..8ec8985 100644 --- a/apps/web/src/components/knowledge/__tests__/LinkAutocomplete.test.tsx +++ b/apps/web/src/components/knowledge/__tests__/LinkAutocomplete.test.tsx @@ -207,6 +207,151 @@ describe("LinkAutocomplete", (): void => { vi.useRealTimers(); }); + it("should show error message when search fails", async (): Promise => { + vi.useFakeTimers(); + + mockApiRequest.mockRejectedValue(new Error("Network error")); + + render(); + + const textarea = textareaRef.current; + if (!textarea) throw new Error("Textarea not found"); + + // Simulate typing [[fail + act(() => { + textarea.value = "[[fail"; + textarea.setSelectionRange(6, 6); + fireEvent.input(textarea); + }); + + // Advance past debounce to fire the search + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Allow microtasks (promise rejection handler) to settle + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Should show PDA-friendly error message instead of "No entries found" + expect(screen.getByText("Search unavailable — please try again")).toBeInTheDocument(); + + // Verify "No entries found" is NOT shown (error takes precedence) + expect(screen.queryByText("No entries found")).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("should clear error message on successful search", async (): Promise => { + vi.useFakeTimers(); + + // First search fails + mockApiRequest.mockRejectedValueOnce(new Error("Network error")); + + render(); + + const textarea = textareaRef.current; + if (!textarea) throw new Error("Textarea not found"); + + // Trigger failing search + act(() => { + textarea.value = "[[fail"; + textarea.setSelectionRange(6, 6); + fireEvent.input(textarea); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Allow microtasks (promise rejection handler) to settle + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(screen.getByText("Search unavailable — please try again")).toBeInTheDocument(); + + // Second search succeeds + mockApiRequest.mockResolvedValueOnce({ + data: [ + { + id: "1", + slug: "test-entry", + title: "Test Entry", + summary: "A test entry", + workspaceId: "workspace-1", + content: "Content", + contentHtml: "

Content

", + status: "PUBLISHED" as const, + visibility: "PUBLIC" as const, + createdBy: "user-1", + updatedBy: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + tags: [], + }, + ], + meta: { total: 1, page: 1, limit: 10, totalPages: 1 }, + }); + + // Trigger successful search + act(() => { + textarea.value = "[[success"; + textarea.setSelectionRange(9, 9); + fireEvent.input(textarea); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Allow microtasks (promise resolution handler) to settle + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Error message should be gone, results should show + expect(screen.queryByText("Search unavailable — please try again")).not.toBeInTheDocument(); + expect(screen.getByText("Test Entry")).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("should not show error for aborted requests", async (): Promise => { + vi.useFakeTimers(); + + // Make the API reject with an AbortError + const abortError = new DOMException("The operation was aborted.", "AbortError"); + mockApiRequest.mockRejectedValue(abortError); + + render(); + + const textarea = textareaRef.current; + if (!textarea) throw new Error("Textarea not found"); + + // Simulate typing [[abc + act(() => { + textarea.value = "[[abc"; + textarea.setSelectionRange(5, 5); + fireEvent.input(textarea); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Should NOT show error message for aborted requests + // Allow a tick for the catch to process + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(screen.queryByText("Search unavailable — please try again")).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + // TODO: Fix async/timer interaction - component works but test has timing issues with fake timers it.skip("should perform debounced search when typing query", async (): Promise => { vi.useFakeTimers();