useDocsSearch.ts298 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { compact, debounce, uniqBy } from 'lodash' |
| 4 | import { useCallback, useMemo, useReducer, useRef } from 'react' |
| 5 | |
| 6 | import { isFeatureEnabled } from '../enabled-features' |
| 7 | |
| 8 | const NUMBER_SOURCES = 2 |
| 9 | |
| 10 | const BRIVEN_URL = process.env.NEXT_PUBLIC_BRIVEN_URL |
| 11 | const BRIVEN_ANON_KEY = process.env.NEXT_PUBLIC_BRIVEN_ANON_KEY |
| 12 | const FUNCTIONS_URL = '/functions/v1/' |
| 13 | |
| 14 | enum PageType { |
| 15 | Markdown = 'markdown', |
| 16 | Reference = 'reference', |
| 17 | Integration = 'partner-integration', |
| 18 | GithubDiscussion = 'github-discussions', |
| 19 | Troubleshooting = 'troubleshooting', |
| 20 | } |
| 21 | |
| 22 | interface PageSection { |
| 23 | heading: string |
| 24 | slug: string |
| 25 | } |
| 26 | |
| 27 | interface Page { |
| 28 | id: number |
| 29 | path: string |
| 30 | type: PageType |
| 31 | title: string |
| 32 | subtitle: string | null |
| 33 | description: string | null |
| 34 | sections: PageSection[] |
| 35 | } |
| 36 | |
| 37 | type SearchState = |
| 38 | | { |
| 39 | status: 'initial' |
| 40 | key: number |
| 41 | } |
| 42 | | { |
| 43 | status: 'loading' |
| 44 | key: number |
| 45 | staleResults: Page[] |
| 46 | } |
| 47 | | { |
| 48 | status: 'partialResults' |
| 49 | key: number |
| 50 | results: Page[] |
| 51 | } |
| 52 | | { |
| 53 | status: 'fullResults' |
| 54 | key: number |
| 55 | results: Page[] |
| 56 | } |
| 57 | | { |
| 58 | status: 'noResults' |
| 59 | key: number |
| 60 | } |
| 61 | | { |
| 62 | status: 'error' |
| 63 | key: number |
| 64 | message: string |
| 65 | } |
| 66 | |
| 67 | type Action = |
| 68 | | { |
| 69 | type: 'resultsReturned' |
| 70 | key: number |
| 71 | sourcesLoaded: number |
| 72 | results: unknown[] |
| 73 | } |
| 74 | | { |
| 75 | type: 'newSearchDispatched' |
| 76 | key: number |
| 77 | } |
| 78 | | { |
| 79 | type: 'reset' |
| 80 | key: number |
| 81 | } |
| 82 | | { |
| 83 | type: 'errored' |
| 84 | key: number |
| 85 | sourcesLoaded: number |
| 86 | message: string |
| 87 | } |
| 88 | |
| 89 | function reshapeResults(result: unknown): Page | null { |
| 90 | if (typeof result !== 'object' || result === null) { |
| 91 | return null |
| 92 | } |
| 93 | if (!('id' in result && 'path' in result && 'type' in result && 'title' in result)) { |
| 94 | return null |
| 95 | } |
| 96 | |
| 97 | const sections: PageSection[] = [] |
| 98 | if ( |
| 99 | 'headings' in result && |
| 100 | Array.isArray(result.headings) && |
| 101 | 'slugs' in result && |
| 102 | Array.isArray(result.slugs) && |
| 103 | result.headings.length === result.slugs.length |
| 104 | ) { |
| 105 | result.headings.forEach((heading, idx) => { |
| 106 | const slug = (result.slugs as Array<string>)[idx] |
| 107 | if (heading && slug) { |
| 108 | sections.push({ heading, slug }) |
| 109 | } |
| 110 | }) |
| 111 | } |
| 112 | |
| 113 | return { |
| 114 | id: result.id as number, |
| 115 | path: result.path as string, |
| 116 | type: result.type as PageType, |
| 117 | title: result.title as string, |
| 118 | subtitle: 'subtitle' in result ? (result.subtitle as string) : null, |
| 119 | description: 'description' in result ? (result.description as string) : null, |
| 120 | sections, |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | function reducer(state: SearchState, action: Action): SearchState { |
| 125 | // Ignore responses from outdated async functions |
| 126 | if (state.key > action.key) { |
| 127 | return state |
| 128 | } |
| 129 | switch (action.type) { |
| 130 | case 'resultsReturned': |
| 131 | const allSourcesLoaded = action.sourcesLoaded === NUMBER_SOURCES |
| 132 | const newResults = compact(action.results.map(reshapeResults)) |
| 133 | // If the new responses are from the same request as the current responses, |
| 134 | // combine the responses. |
| 135 | // If the new responses are from a fresher request, replace the current responses. |
| 136 | const allResults = |
| 137 | state.status === 'partialResults' && state.key === action.key |
| 138 | ? uniqBy(state.results.concat(newResults), (res) => res.id) |
| 139 | : newResults |
| 140 | if (!allResults.length) { |
| 141 | return allSourcesLoaded |
| 142 | ? { |
| 143 | status: 'noResults', |
| 144 | key: action.key, |
| 145 | } |
| 146 | : { |
| 147 | status: 'loading', |
| 148 | key: action.key, |
| 149 | staleResults: |
| 150 | 'results' in state |
| 151 | ? state.results |
| 152 | : 'staleResults' in state |
| 153 | ? state.staleResults |
| 154 | : [], |
| 155 | } |
| 156 | } |
| 157 | return allSourcesLoaded |
| 158 | ? { |
| 159 | status: 'fullResults', |
| 160 | key: action.key, |
| 161 | results: allResults, |
| 162 | } |
| 163 | : { |
| 164 | status: 'partialResults', |
| 165 | key: action.key, |
| 166 | results: allResults, |
| 167 | } |
| 168 | case 'newSearchDispatched': |
| 169 | return { |
| 170 | status: 'loading', |
| 171 | key: action.key, |
| 172 | staleResults: |
| 173 | 'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [], |
| 174 | } |
| 175 | case 'reset': |
| 176 | return { |
| 177 | status: 'initial', |
| 178 | key: action.key, |
| 179 | } |
| 180 | case 'errored': |
| 181 | // At least one search has failed and all non-failing searches have come back empty |
| 182 | if (action.sourcesLoaded === NUMBER_SOURCES && !('results' in state)) { |
| 183 | return { |
| 184 | status: 'error', |
| 185 | key: action.key, |
| 186 | message: action.message, |
| 187 | } |
| 188 | } |
| 189 | return state |
| 190 | default: |
| 191 | return state |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | const useDocsSearch = () => { |
| 196 | const [state, dispatch] = useReducer(reducer, { status: 'initial', key: 0 }) |
| 197 | const key = useRef(0) |
| 198 | |
| 199 | const handleSearch = useCallback(async (query: string) => { |
| 200 | key.current += 1 |
| 201 | const localKey = key.current |
| 202 | dispatch({ type: 'newSearchDispatched', key: localKey }) |
| 203 | |
| 204 | let sourcesLoaded = 0 |
| 205 | |
| 206 | const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') |
| 207 | |
| 208 | const searchEndpoint = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts' |
| 209 | fetch(`${BRIVEN_URL}/rest/v1/rpc/${searchEndpoint}`, { |
| 210 | method: 'POST', |
| 211 | headers: { |
| 212 | 'content-type': 'application/json', |
| 213 | ...(BRIVEN_ANON_KEY && { |
| 214 | apikey: BRIVEN_ANON_KEY, |
| 215 | authorization: `Bearer ${BRIVEN_ANON_KEY}`, |
| 216 | }), |
| 217 | }, |
| 218 | body: JSON.stringify({ query: query.trim() }), |
| 219 | }) |
| 220 | .then((res) => res.json()) |
| 221 | .then((data) => { |
| 222 | sourcesLoaded += 1 |
| 223 | if (!Array.isArray(data)) { |
| 224 | dispatch({ |
| 225 | type: 'errored', |
| 226 | key: localKey, |
| 227 | sourcesLoaded, |
| 228 | message: data?.message ?? '', |
| 229 | }) |
| 230 | } else { |
| 231 | dispatch({ |
| 232 | type: 'resultsReturned', |
| 233 | key: localKey, |
| 234 | sourcesLoaded, |
| 235 | results: data, |
| 236 | }) |
| 237 | } |
| 238 | }) |
| 239 | .catch((error: unknown) => { |
| 240 | sourcesLoaded += 1 |
| 241 | console.error(`[ERROR] Error fetching Full Text Search results: ${error}`) |
| 242 | |
| 243 | dispatch({ |
| 244 | type: 'errored', |
| 245 | key: localKey, |
| 246 | sourcesLoaded, |
| 247 | message: '', |
| 248 | }) |
| 249 | }) |
| 250 | |
| 251 | fetch(`${BRIVEN_URL}${FUNCTIONS_URL}search-embeddings`, { |
| 252 | method: 'POST', |
| 253 | body: JSON.stringify({ query, useAlternateSearchIndex }), |
| 254 | }) |
| 255 | .then((response) => response.json()) |
| 256 | .then((results) => { |
| 257 | if (!Array.isArray(results)) { |
| 258 | throw Error("didn't get expected results array") |
| 259 | } |
| 260 | sourcesLoaded += 1 |
| 261 | dispatch({ |
| 262 | type: 'resultsReturned', |
| 263 | key: localKey, |
| 264 | sourcesLoaded, |
| 265 | results, |
| 266 | }) |
| 267 | }) |
| 268 | .catch((error) => { |
| 269 | sourcesLoaded += 1 |
| 270 | dispatch({ |
| 271 | type: 'errored', |
| 272 | key: localKey, |
| 273 | sourcesLoaded, |
| 274 | message: error.message ?? '', |
| 275 | }) |
| 276 | }) |
| 277 | }, []) |
| 278 | |
| 279 | const debouncedSearch = useMemo(() => debounce(handleSearch, 150), [handleSearch]) |
| 280 | |
| 281 | const resetSearch = useCallback(() => { |
| 282 | key.current += 1 |
| 283 | dispatch({ |
| 284 | type: 'reset', |
| 285 | key: key.current, |
| 286 | }) |
| 287 | }, []) |
| 288 | |
| 289 | return { |
| 290 | searchState: state, |
| 291 | handleDocsSearch: handleSearch, |
| 292 | handleDocsSearchDebounced: debouncedSearch, |
| 293 | resetSearch, |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | export { useDocsSearch, PageType as DocsSearchResultType } |
| 298 | export type { Page as DocsSearchResult, PageSection as DocsSearchResultSection } |