useDocsSearch.ts298 lines · main
1'use client'
2
3import { compact, debounce, uniqBy } from 'lodash'
4import { useCallback, useMemo, useReducer, useRef } from 'react'
5
6import { isFeatureEnabled } from '../enabled-features'
7
8const NUMBER_SOURCES = 2
9
10const BRIVEN_URL = process.env.NEXT_PUBLIC_BRIVEN_URL
11const BRIVEN_ANON_KEY = process.env.NEXT_PUBLIC_BRIVEN_ANON_KEY
12const FUNCTIONS_URL = '/functions/v1/'
13
14enum PageType {
15 Markdown = 'markdown',
16 Reference = 'reference',
17 Integration = 'partner-integration',
18 GithubDiscussion = 'github-discussions',
19 Troubleshooting = 'troubleshooting',
20}
21
22interface PageSection {
23 heading: string
24 slug: string
25}
26
27interface 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
37type 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
67type 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
89function 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
124function 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
195const 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
297export { useDocsSearch, PageType as DocsSearchResultType }
298export type { Page as DocsSearchResult, PageSection as DocsSearchResultSection }