sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
165 lines (153 loc) • 5.22 kB
text/typescript
import {type Schema} from '@sanity/types'
import isEqual from 'lodash/isEqual'
import {useCallback, useMemo, useState} from 'react'
import {useObservableCallback} from 'react-rx'
import {concat, EMPTY, iif, type Observable, of, timer} from 'rxjs'
import {
catchError,
debounce,
distinctUntilChanged,
filter,
map,
scan,
switchMap,
tap,
} from 'rxjs/operators'
import {useClient} from '../../../../../hooks'
import {
createSearch,
type SearchHit,
type SearchOptions,
type SearchTerms,
} from '../../../../../search'
import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../../../studioClient'
import {useWorkspace} from '../../../../workspace'
import {type SearchState} from '../types'
import {hasSearchableTerms} from '../utils/hasSearchableTerms'
import {getSearchableOmnisearchTypes} from '../utils/selectors'
import {useSearchMaxFieldDepth} from './useSearchMaxFieldDepth'
interface SearchRequest {
debounceTime?: number
options?: SearchOptions
terms: SearchTerms
}
const DEFAULT_DEBOUNCE_TIME = 300 // ms
const INITIAL_SEARCH_STATE: SearchState = {
error: null,
hits: [],
loading: false,
terms: {
query: '',
types: [],
},
}
function nonNullable<T>(v: T): v is NonNullable<T> {
return v !== null
}
function sanitizeRequest(request: SearchRequest) {
return {
...request,
terms: {
...request.terms,
filter: request.terms.filter?.trim(),
query: request.terms.query.trim(),
},
}
}
export function useSearch({
allowEmptyQueries,
initialState,
onComplete,
onError,
onStart,
schema,
}: {
allowEmptyQueries?: boolean
initialState: SearchState
onComplete?: (result: {hits: SearchHit[]; nextCursor: string | undefined}) => void
onError?: (error: Error) => void
onStart?: () => void
schema: Schema
}): {
handleSearch: (request: SearchRequest) => void
searchState: SearchState
} {
const [searchState, setSearchState] = useState(initialState)
const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)
const maxFieldDepth = useSearchMaxFieldDepth()
const {unstable_enableNewSearch = false} = useWorkspace().search
const search = useMemo(
() =>
createSearch(getSearchableOmnisearchTypes(schema), client, {
tag: 'search.global',
unique: true,
unstable_enableNewSearch,
maxDepth: maxFieldDepth,
}),
[client, maxFieldDepth, schema, unstable_enableNewSearch],
)
const handleQueryChange = useObservableCallback(
(inputValue$: Observable<SearchRequest | null>) => {
return inputValue$.pipe(
// Ignore null values
filter(nonNullable),
// Sanitize request (trim query and filter)
map(sanitizeRequest),
// Only emit when values have changed
distinctUntilChanged(isEqual),
// Debounce requests
debounce((request) => timer(request?.debounceTime || DEFAULT_DEBOUNCE_TIME)),
// Trigger `onStart` callback
tap(onStart),
switchMap((request) => {
return concat(
// Emit loading start
of({
...INITIAL_SEARCH_STATE,
loading: true,
options: request.options,
terms: request.terms,
}),
// Conditionally trigger search ONLY if we have valid searchable terms.
// Typically, search terms are valid if either query, filter or selected types is non-empty.
// There are exceptions (e.g. searching within <AutoComplete> components) where empty queries are permitted,
// which is what `allowEmptyQueries` is used for.
iif(
() => hasSearchableTerms({allowEmptyQueries, terms: request.terms}),
// If we have a valid search, run async fetch, map results and trigger `onComplete` / `onError` callbacks
search(request.terms, request.options).pipe(
tap(({hits, nextCursor}) => onComplete?.({hits, nextCursor})),
catchError((error) => {
onError?.(error)
return of({
...INITIAL_SEARCH_STATE,
error,
loading: false,
options: request.options,
terms: request.terms,
})
}),
),
// If there is no valid search, emit an empty observable and trigger `onComplete` event
of(EMPTY).pipe(tap(() => onComplete?.({hits: [], nextCursor: undefined}))),
),
// Emit loading completed
of({loading: false}),
)
}),
scan((prevState, nextState): SearchState => {
return {...prevState, ...nextState}
}, INITIAL_SEARCH_STATE),
// Update local search state
tap(setSearchState),
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- @todo: add onComplete, onError and onStart to the deps list when it's verified that it's safe to do so
[allowEmptyQueries, search],
)
const handleSearch = useCallback(
(searchRequest: SearchRequest) => handleQueryChange(searchRequest),
[handleQueryChange],
)
return {handleSearch, searchState}
}