debounceSearch.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import type {ReactElement} from 'react';
  2. import {useCallback, useEffect, useRef, useState} from 'react';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import SearchBar from 'sentry/components/searchBar';
  7. import {space} from 'sentry/styles/space';
  8. import useApi from 'sentry/utils/useApi';
  9. import useKeyPress from 'sentry/utils/useKeyPress';
  10. type Props = {
  11. onSelectResult: (value: string) => void;
  12. path: string;
  13. placeholder: string;
  14. suggestionContent: (suggestion: any) => ReactElement;
  15. createSuggestionPath?: (suggestion: any) => string;
  16. host?: string;
  17. onSearch?: (value: string) => void;
  18. queryParam?: string;
  19. };
  20. function DebounceSearch({
  21. createSuggestionPath,
  22. onSearch,
  23. onSelectResult,
  24. host,
  25. path,
  26. placeholder,
  27. queryParam = '',
  28. suggestionContent,
  29. }: Props) {
  30. const [error, setError] = useState('');
  31. const [loading, setLoading] = useState(false);
  32. const [query, setQuery] = useState('');
  33. const [queryResults, setQueryResults] = useState([]);
  34. const [showResults, setShowResults] = useState(false);
  35. const [node, setNode] = useState<HTMLDivElement | null>();
  36. const setKeyHandlers = useCallback((nodeRef: HTMLDivElement | null) => {
  37. setNode(nodeRef);
  38. }, []);
  39. const downPress = useKeyPress('ArrowDown', node);
  40. const upPress = useKeyPress('ArrowUp', node);
  41. const enterPress = useKeyPress('Enter', node);
  42. const escapePress = useKeyPress('Escape', node);
  43. const [cursor, setCursor] = useState<number>(0);
  44. const api = useApi();
  45. const debouncedSearch = useRef(
  46. debounce(async (searchHost, value) => {
  47. // Avoid slow-fetch race conditions
  48. api.clear();
  49. setError('');
  50. setQueryResults([]);
  51. if (value) {
  52. try {
  53. const queryParams = {
  54. query: [queryParam, value].filter(v => v).join(':'),
  55. per_page: 10,
  56. };
  57. const results = await api.requestPromise(path, {
  58. method: 'GET',
  59. host: searchHost,
  60. data: queryParams,
  61. });
  62. setQueryResults(results);
  63. } catch (err) {
  64. setError(err.message);
  65. }
  66. }
  67. setLoading(false);
  68. value ? setShowResults(true) : setShowResults(false);
  69. }, 300)
  70. ).current;
  71. const onChange = useCallback(
  72. (value: string) => {
  73. value ? setLoading(true) : setLoading(false);
  74. value ? setShowResults(true) : setShowResults(false);
  75. setQuery(value);
  76. debouncedSearch(host, value);
  77. },
  78. [host, debouncedSearch]
  79. );
  80. useEffect(() => {
  81. if (queryResults.length && downPress) {
  82. setCursor(prevState =>
  83. prevState < queryResults.length ? prevState + 1 : prevState
  84. );
  85. }
  86. }, [downPress, queryResults.length]);
  87. useEffect(() => {
  88. if (queryResults.length && upPress) {
  89. setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
  90. }
  91. }, [upPress, queryResults.length]);
  92. useEffect(() => {
  93. if (enterPress && cursor === 0) {
  94. if (onSearch) {
  95. onSearch(query);
  96. } else {
  97. onChange(query);
  98. }
  99. } else if (enterPress && cursor <= queryResults.length) {
  100. const item = queryResults[cursor - 1]!;
  101. onSelectResult(item);
  102. }
  103. }, [cursor, enterPress, onChange, onSearch, onSelectResult, query, queryResults]);
  104. useEffect(() => {
  105. api.clear();
  106. setCursor(0);
  107. setError('');
  108. setLoading(false);
  109. setQueryResults([]);
  110. setShowResults(false);
  111. }, [escapePress, debouncedSearch, api, host]);
  112. const renderSuggestion = (item: any, idx: number) => {
  113. return (
  114. <a
  115. target="_blank"
  116. href={createSuggestionPath?.(item)}
  117. rel="noreferrer"
  118. key={item.id}
  119. >
  120. <SuggestionCard highlight={cursor === idx + 1}>
  121. {suggestionContent(item)}
  122. </SuggestionCard>
  123. </a>
  124. );
  125. };
  126. return (
  127. <div>
  128. <div ref={setKeyHandlers}>
  129. <SearchBar
  130. placeholder={placeholder}
  131. onChange={onChange}
  132. style={error ? {border: '1px solid red'} : {}}
  133. />
  134. </div>
  135. <SearchResults>
  136. {loading && <LoadingIndicator />}
  137. {!loading && showResults && queryResults.map(renderSuggestion)}
  138. {!loading && showResults && !queryResults.length && <Card>No results found</Card>}
  139. </SearchResults>
  140. {error && <Error>{error}</Error>}
  141. </div>
  142. );
  143. }
  144. const Card = styled('div')<{highlight?: boolean}>`
  145. color: ${p => (p.highlight ? p.theme.active : p.theme.textColor)};
  146. background: ${p => (p.highlight ? p.theme.gray100 : p.theme.background)};
  147. box-shadow: ${p => p.theme.dropShadowMedium};
  148. padding: ${space(2)};
  149. `;
  150. const Error = styled('div')`
  151. color: red;
  152. `;
  153. const SearchResults = styled('div')`
  154. margin-bottom: ${space(2)};
  155. `;
  156. const SuggestionCard = styled(Card)`
  157. &:hover {
  158. color: ${p => p.theme.active};
  159. background: ${p => p.theme.gray100};
  160. cursor: pointer;
  161. }
  162. `;
  163. export default DebounceSearch;