diff --git a/app/_components/algolia-search.tsx b/app/_components/algolia-search.tsx index f808c2e8d..0fccac74e 100644 --- a/app/_components/algolia-search.tsx +++ b/app/_components/algolia-search.tsx @@ -2,7 +2,7 @@ import { liteClient as algoliasearch } from "algoliasearch/lite"; import { Search } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Configure, Highlight, @@ -41,6 +41,82 @@ const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME; const searchClient = appId && searchKey ? algoliasearch(appId, searchKey) : null; +export const ALGOLIA_SEARCH_CONFIG = { + attributesToSnippet: ["content:20"], + distinct: true, + highlightPreTag: "__ais-highlight__", + highlightPostTag: "__/ais-highlight__", + hitsPerPage: 15, + snippetEllipsisText: "…", +}; +export const ALGOLIA_SEARCH_DEBOUNCE_MS = 150; + +type SearchStatus = "idle" | "loading" | "stalled" | "error"; +type SearchTimer = ReturnType; + +export function getSearchErrorMessage(status: SearchStatus): string | null { + return status === "error" + ? "Search failed. Check your connection and try again." + : null; +} + +export function searchErrorIsCurrent( + query: string, + dispatchedQuery: string, + status: SearchStatus +): boolean { + return ( + status === "error" && + query.trim().length > 0 && + dispatchedQuery.trim() === query.trim() + ); +} + +type ScheduleSearchOptions = { + query: string; + search: (nextQuery: string) => void; + setTypedQuery: (nextQuery: string) => void; + setDispatchedQuery?: (nextQuery: string) => void; + currentTimer: SearchTimer | null; + delayMs?: number; +}; + +export function scheduleSearch({ + query, + search, + setTypedQuery, + setDispatchedQuery, + currentTimer, + delayMs = ALGOLIA_SEARCH_DEBOUNCE_MS, +}: ScheduleSearchOptions): SearchTimer | null { + setTypedQuery(query); + if (currentTimer) { + clearTimeout(currentTimer); + } + if (!query.trim()) { + setDispatchedQuery?.(query); + search(query); + return null; + } + return setTimeout(() => { + setDispatchedQuery?.(query); + search(query); + }, delayMs); +} + +export function searchResultsAreCurrent( + query: string, + resultsQuery: string, + status: SearchStatus +): boolean { + const normalizedQuery = query.trim(); + return ( + normalizedQuery.length > 0 && + status === "idle" && + resultsQuery.trim() === normalizedQuery + ); +} + function safeHref(url: string | undefined): string { if (!url) { return "/"; @@ -138,28 +214,117 @@ function SearchHit({ hit }: { hit: DocSearchRecord }) { ); } -function EmptyQuery() { - const { indexUiState } = useInstantSearch(); - if (indexUiState.query) { - return null; +function SearchResults({ + query, + dispatchedQuery, +}: { + query: string; + dispatchedQuery: string; +}) { + const { results, status } = useInstantSearch({ catchError: true }); + if (!query.trim()) { + return ( +

+ Start typing to search the docs… +

+ ); + } + + const errorMessage = searchErrorIsCurrent(query, dispatchedQuery, status) + ? getSearchErrorMessage(status) + : null; + if (errorMessage) { + return ( +

+ {errorMessage} +

+ ); } + + if ( + !(results && searchResultsAreCurrent(query, results.query ?? "", status)) + ) { + return ( +

+ Searching… +

+ ); + } + + if (results.nbHits === 0) { + return ( +

+ No results for{" "} + "{results.query}" +

+ ); + } + return ( -

- Start typing to search the docs… -

+ ( + + )} + /> ); } -function NoResults() { - const { results } = useInstantSearch(); - if (!results?.query || results.nbHits > 0) { - return null; - } +type SearchQueryHook = ( + query: string, + search: (nextQuery: string) => void +) => void; + +function SearchContent() { + const [typedQuery, setTypedQuery] = useState(""); + const [dispatchedQuery, setDispatchedQuery] = useState(""); + const searchTimerRef = useRef | null>(null); + const queryHook = useCallback((query, search) => { + searchTimerRef.current = scheduleSearch({ + query, + search, + setTypedQuery, + setDispatchedQuery, + currentTimer: searchTimerRef.current, + }); + }, []); + + useEffect( + () => () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + }, + [] + ); + return ( -

- No results for{" "} - "{results.query}" -

+ <> + +
+ + +
+
+ +
+ ); } @@ -222,38 +387,7 @@ export function AlgoliaSearch() {
{searchClient && indexName ? ( - -
- - -
-
- - - ( - - )} - /> -
+
) : ( diff --git a/tests/algolia-search.test.ts b/tests/algolia-search.test.ts new file mode 100644 index 000000000..55b113b63 --- /dev/null +++ b/tests/algolia-search.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ALGOLIA_SEARCH_CONFIG, + ALGOLIA_SEARCH_DEBOUNCE_MS, + getSearchErrorMessage, + scheduleSearch, + searchErrorIsCurrent, + searchResultsAreCurrent, +} from "../app/_components/algolia-search"; + +describe("Algolia search configuration", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("uses the highlight markers expected by React InstantSearch", () => { + expect(ALGOLIA_SEARCH_CONFIG.highlightPreTag).toBe("__ais-highlight__"); + expect(ALGOLIA_SEARCH_CONFIG.highlightPostTag).toBe("__/ais-highlight__"); + }); + + test("debounces search requests while typing", () => { + vi.useFakeTimers(); + const search = vi.fn(); + const setDispatchedQuery = vi.fn(); + const setTypedQuery = vi.fn(); + let timer = scheduleSearch({ + query: "g", + search, + setDispatchedQuery, + setTypedQuery, + currentTimer: null, + }); + timer = scheduleSearch({ + query: "github", + search, + setDispatchedQuery, + setTypedQuery, + currentTimer: timer, + }); + + expect(setTypedQuery).toHaveBeenLastCalledWith("github"); + expect(search).not.toHaveBeenCalled(); + vi.advanceTimersByTime(ALGOLIA_SEARCH_DEBOUNCE_MS); + expect(search).toHaveBeenCalledOnce(); + expect(search).toHaveBeenCalledWith("github"); + expect(setDispatchedQuery).toHaveBeenCalledWith("github"); + + scheduleSearch({ + query: "", + search, + setDispatchedQuery, + setTypedQuery, + currentTimer: timer, + }); + expect(search).toHaveBeenLastCalledWith(""); + }); + + test("does not render stale results while a new query is pending", () => { + expect(searchResultsAreCurrent("slack", "github", "idle")).toBe(false); + expect(searchResultsAreCurrent("slack", "slack", "loading")).toBe(false); + expect(searchResultsAreCurrent("slack", "slack", "stalled")).toBe(false); + expect(searchResultsAreCurrent("slack", "slack", "idle")).toBe(true); + }); + + test("surfaces failed searches instead of leaving a loading state", () => { + expect(getSearchErrorMessage("error")).toBe( + "Search failed. Check your connection and try again." + ); + expect(getSearchErrorMessage("loading")).toBeNull(); + }); + + test("does not show an old error for a newly typed query", () => { + expect(searchErrorIsCurrent("slack", "github", "error")).toBe(false); + expect(searchErrorIsCurrent("slack", "slack", "error")).toBe(true); + }); +});