import type {ReactElement} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import debounce from 'lodash/debounce'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import SearchBar from 'sentry/components/searchBar'; import {space} from 'sentry/styles/space'; import useApi from 'sentry/utils/useApi'; import useKeyPress from 'sentry/utils/useKeyPress'; type Props = { onSelectResult: (value: string) => void; path: string; placeholder: string; suggestionContent: (suggestion: any) => ReactElement; createSuggestionPath?: (suggestion: any) => string; host?: string; onSearch?: (value: string) => void; queryParam?: string; }; function DebounceSearch({ createSuggestionPath, onSearch, onSelectResult, host, path, placeholder, queryParam = '', suggestionContent, }: Props) { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(''); const [queryResults, setQueryResults] = useState([]); const [showResults, setShowResults] = useState(false); const [node, setNode] = useState<HTMLDivElement | null>(); const setKeyHandlers = useCallback((nodeRef: HTMLDivElement | null) => { setNode(nodeRef); }, []); const downPress = useKeyPress('ArrowDown', node); const upPress = useKeyPress('ArrowUp', node); const enterPress = useKeyPress('Enter', node); const escapePress = useKeyPress('Escape', node); const [cursor, setCursor] = useState<number>(0); const api = useApi(); const debouncedSearch = useRef( debounce(async (searchHost, value) => { // Avoid slow-fetch race conditions api.clear(); setError(''); setQueryResults([]); if (value) { try { const queryParams = { query: [queryParam, value].filter(v => v).join(':'), per_page: 10, }; const results = await api.requestPromise(path, { method: 'GET', host: searchHost, data: queryParams, }); setQueryResults(results); } catch (err) { setError(err.message); } } setLoading(false); value ? setShowResults(true) : setShowResults(false); }, 300) ).current; const onChange = useCallback( (value: string) => { value ? setLoading(true) : setLoading(false); value ? setShowResults(true) : setShowResults(false); setQuery(value); debouncedSearch(host, value); }, [host, debouncedSearch] ); useEffect(() => { if (queryResults.length && downPress) { setCursor(prevState => prevState < queryResults.length ? prevState + 1 : prevState ); } }, [downPress, queryResults.length]); useEffect(() => { if (queryResults.length && upPress) { setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState)); } }, [upPress, queryResults.length]); useEffect(() => { if (enterPress && cursor === 0) { if (onSearch) { onSearch(query); } else { onChange(query); } } else if (enterPress && cursor <= queryResults.length) { const item = queryResults[cursor - 1]!; onSelectResult(item); } }, [cursor, enterPress, onChange, onSearch, onSelectResult, query, queryResults]); useEffect(() => { api.clear(); setCursor(0); setError(''); setLoading(false); setQueryResults([]); setShowResults(false); }, [escapePress, debouncedSearch, api, host]); const renderSuggestion = (item: any, idx: number) => { return ( <a target="_blank" href={createSuggestionPath?.(item)} rel="noreferrer" key={item.id} > <SuggestionCard highlight={cursor === idx + 1}> {suggestionContent(item)} </SuggestionCard> </a> ); }; return ( <div> <div ref={setKeyHandlers}> <SearchBar placeholder={placeholder} onChange={onChange} style={error ? {border: '1px solid red'} : {}} /> </div> <SearchResults> {loading && <LoadingIndicator />} {!loading && showResults && queryResults.map(renderSuggestion)} {!loading && showResults && !queryResults.length && <Card>No results found</Card>} </SearchResults> {error && <Error>{error}</Error>} </div> ); } const Card = styled('div')<{highlight?: boolean}>` color: ${p => (p.highlight ? p.theme.active : p.theme.textColor)}; background: ${p => (p.highlight ? p.theme.gray100 : p.theme.background)}; box-shadow: ${p => p.theme.dropShadowMedium}; padding: ${space(2)}; `; const Error = styled('div')` color: red; `; const SearchResults = styled('div')` margin-bottom: ${space(2)}; `; const SuggestionCard = styled(Card)` &:hover { color: ${p => p.theme.active}; background: ${p => p.theme.gray100}; cursor: pointer; } `; export default DebounceSearch;