Files
supabase/packages/common/hooks/useDocsSearchV2.ts

141 lines
4.4 KiB
TypeScript

'use client'
import { compact, debounce } from 'lodash'
import { useCallback, useMemo, useReducer, useRef } from 'react'
// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs').
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
// Public URL of the docs deployment, which hosts the search API routes.
const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs'
// From inside the docs app itself, call our own routes relatively; from studio/www, call docs directly.
const SEARCH_API_BASE = BASE_PATH === '/docs' ? BASE_PATH : DOCS_URL
interface DocsSearchV2Result {
path: string
title: string
heading: string
excerpt: string
headingPath: string[]
score: number
}
type SearchState =
| { status: 'initial'; key: number }
| { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] }
| { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string }
| { status: 'noResults'; key: number; query: string }
| { status: 'error'; key: number; message: string }
type Action =
| { type: 'resultsReturned'; key: number; results: DocsSearchV2Result[]; query: string }
| { type: 'newSearchDispatched'; key: number }
| { type: 'reset'; key: number }
| { type: 'errored'; key: number; message: string }
function reshapeResult(row: unknown): DocsSearchV2Result | null {
if (typeof row !== 'object' || row === null) return null
if (
!(
'slug' in row &&
'page_title' in row &&
'heading' in row &&
'excerpt' in row &&
'heading_path' in row &&
'score' in row
)
)
return null
const slug = row.slug as string
return {
// Next's router already prepends the app's basePath ('/docs'), so don't add it here.
path: slug ? `/${slug}` : '/',
title: row.page_title as string,
heading: row.heading as string,
excerpt: row.excerpt as string,
headingPath: row.heading_path as string[],
score: row.score as number,
}
}
function reducer(state: SearchState, action: Action): SearchState {
// Ignore responses from outdated async functions
if (state.key > action.key) {
return state
}
switch (action.type) {
case 'resultsReturned':
return action.results.length
? { status: 'results', key: action.key, results: action.results, query: action.query }
: { status: 'noResults', key: action.key, query: action.query }
case 'newSearchDispatched':
return {
status: 'loading',
key: action.key,
staleResults:
'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [],
}
case 'reset':
return { status: 'initial', key: action.key }
case 'errored':
return { status: 'error', key: action.key, message: action.message }
default:
return state
}
}
const useDocsSearchV2 = () => {
const [state, dispatch] = useReducer(reducer, { status: 'initial', key: 0 })
const key = useRef(0)
const handleSearch = useCallback(async (query: string) => {
key.current += 1
const localKey = key.current
dispatch({ type: 'newSearchDispatched', key: localKey })
try {
const params = new URLSearchParams({ q: query.trim() })
const response = await fetch(`${SEARCH_API_BASE}/api/search_v2?${params}`)
const data = await response.json()
if (!Array.isArray(data)) {
throw new Error(data?.error ?? "didn't get expected results array")
}
dispatch({
type: 'resultsReturned',
key: localKey,
results: compact(data.map(reshapeResult)),
query: query.trim(),
})
} catch (error) {
console.error(`[ERROR] Error fetching docs search v2 results: ${error}`)
dispatch({
type: 'errored',
key: localKey,
message: error instanceof Error ? error.message : '',
})
}
}, [])
const debouncedSearch = useMemo(() => debounce(handleSearch, 250), [handleSearch])
const debounceCancel = useCallback(() => {
debouncedSearch.cancel()
}, [debouncedSearch])
const resetSearch = useCallback(() => {
debounceCancel()
key.current += 1
dispatch({ type: 'reset', key: key.current })
}, [debounceCancel])
return {
searchState: state,
handleDocsSearch: handleSearch,
handleDocsSearchDebounced: debouncedSearch,
resetSearch,
}
}
export { useDocsSearchV2 }
export type { DocsSearchV2Result }