index.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  6. import {navigateTo} from 'sentry/actionCreators/navigation';
  7. import AutoComplete from 'sentry/components/autoComplete';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import SearchResult from 'sentry/components/search/searchResult';
  10. import SearchResultWrapper from 'sentry/components/search/searchResultWrapper';
  11. import SearchSources from 'sentry/components/search/sources';
  12. import ApiSource from 'sentry/components/search/sources/apiSource';
  13. import CommandSource from 'sentry/components/search/sources/commandSource';
  14. import FormSource from 'sentry/components/search/sources/formSource';
  15. import RouteSource from 'sentry/components/search/sources/routeSource';
  16. import {t} from 'sentry/locale';
  17. import space from 'sentry/styles/space';
  18. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  19. import type {Fuse} from 'sentry/utils/fuzzySearch';
  20. import replaceRouterParams from 'sentry/utils/replaceRouterParams';
  21. import {Result} from './sources/types';
  22. interface InputProps
  23. extends Pick<
  24. Parameters<AutoComplete<Result['item']>['props']['children']>[0],
  25. 'getInputProps'
  26. > {}
  27. /**
  28. * Render prop for search results
  29. *
  30. * Args: {
  31. * item: Search Item
  32. * index: item's index in results
  33. * highlighted: is item highlighted
  34. * itemProps: props that should be spread for root item
  35. * }
  36. */
  37. interface ItemProps {
  38. highlighted: boolean;
  39. index: number;
  40. item: Result['item'];
  41. itemProps: React.ComponentProps<typeof SearchResultWrapper>;
  42. matches: Result['matches'];
  43. }
  44. interface SearchProps extends WithRouterProps<{orgId: string}> {
  45. /**
  46. * For analytics
  47. */
  48. entryPoint: 'settings_search' | 'command_palette' | 'sidebar_help';
  49. /**
  50. * Maximum number of results to display
  51. */
  52. maxResults: number;
  53. /**
  54. * Minimum number of characters before search activates
  55. */
  56. minSearch: number;
  57. /**
  58. * Render prop for the main input for the search
  59. */
  60. renderInput: (props: InputProps) => React.ReactNode;
  61. /**
  62. * Passed to the underlying AutoComplete component
  63. */
  64. closeOnSelect?: boolean;
  65. /**
  66. * Additional CSS for the dropdown menu.
  67. */
  68. dropdownStyle?: string;
  69. /**
  70. * Render an item in the search results.
  71. */
  72. renderItem?: (props: ItemProps) => React.ReactElement;
  73. /**
  74. * Adds a footer below the results when the search is complete
  75. */
  76. resultFooter?: React.ReactElement;
  77. /**
  78. * Fuse search options
  79. */
  80. searchOptions?: Fuse.IFuseOptions<any>;
  81. /**
  82. * The sources to query
  83. */
  84. sources?: React.ComponentType[];
  85. }
  86. function Search(props: SearchProps): React.ReactElement {
  87. React.useEffect(() => {
  88. trackAdvancedAnalyticsEvent(`${props.entryPoint}.open`, {
  89. organization: null,
  90. });
  91. }, [props.entryPoint]);
  92. const handleSelectItem = React.useCallback(
  93. (item: Result['item'], state?: AutoComplete<Result['item']>['state']) => {
  94. if (!item) {
  95. return;
  96. }
  97. trackAdvancedAnalyticsEvent(`${props.entryPoint}.select`, {
  98. query: state?.inputValue,
  99. result_type: item.resultType,
  100. source_type: item.sourceType,
  101. organization: null,
  102. });
  103. // `action` refers to a callback function while
  104. // `to` is a react-router route
  105. if (typeof item.action === 'function') {
  106. item.action(item, state);
  107. return;
  108. }
  109. if (!item.to) {
  110. return;
  111. }
  112. if (item.to.startsWith('http')) {
  113. const open = window.open();
  114. if (open) {
  115. open.opener = null;
  116. open.location.href = item.to;
  117. return;
  118. }
  119. addErrorMessage(
  120. t('Unable to open search result (a popup blocker may have caused this).')
  121. );
  122. return;
  123. }
  124. const nextPath = replaceRouterParams(item.to, props.params);
  125. navigateTo(nextPath, props.router, item.configUrl);
  126. },
  127. [props.entryPoint, props.router, props.params]
  128. );
  129. const saveQueryMetrics = React.useCallback(
  130. (query: string) => {
  131. if (!query) {
  132. return;
  133. }
  134. trackAdvancedAnalyticsEvent(`${props.entryPoint}.query`, {
  135. query,
  136. organization: null,
  137. });
  138. },
  139. [props.entryPoint]
  140. );
  141. const debouncedSaveQueryMetrics = React.useMemo(() => {
  142. return debounce(saveQueryMetrics, 200);
  143. }, [props.entryPoint, saveQueryMetrics]);
  144. return (
  145. <AutoComplete
  146. defaultHighlightedIndex={0}
  147. onSelect={handleSelectItem}
  148. closeOnSelect={props.closeOnSelect ?? true}
  149. >
  150. {({getInputProps, getItemProps, isOpen, inputValue, highlightedIndex}) => {
  151. const searchQuery = inputValue.toLowerCase().trim();
  152. const isValidSearch = inputValue.length >= props.minSearch;
  153. debouncedSaveQueryMetrics(searchQuery);
  154. const renderItem =
  155. typeof props.renderItem === 'function'
  156. ? props.renderItem
  157. : ({
  158. item,
  159. matches,
  160. itemProps,
  161. highlighted,
  162. }: ItemProps): React.ReactElement => (
  163. <SearchResultWrapper {...itemProps} highlighted={highlighted}>
  164. <SearchResult highlighted={highlighted} item={item} matches={matches} />
  165. </SearchResultWrapper>
  166. );
  167. return (
  168. <SearchWrapper>
  169. {props.renderInput({getInputProps})}
  170. {isValidSearch && isOpen ? (
  171. <SearchSources
  172. searchOptions={props.searchOptions}
  173. query={searchQuery}
  174. params={props.params}
  175. sources={
  176. props.sources ??
  177. ([
  178. ApiSource,
  179. FormSource,
  180. RouteSource,
  181. CommandSource,
  182. ] as React.ComponentType[])
  183. }
  184. >
  185. {({isLoading, results, hasAnyResults}) => (
  186. <DropdownBox className={props.dropdownStyle}>
  187. {isLoading ? (
  188. <LoadingWrapper>
  189. <LoadingIndicator mini hideMessage relative />
  190. </LoadingWrapper>
  191. ) : !hasAnyResults ? (
  192. <EmptyItem>{t('No results found')}</EmptyItem>
  193. ) : (
  194. results.slice(0, props.maxResults).map((resultObj, index) => {
  195. return React.cloneElement(
  196. renderItem({
  197. index,
  198. item: resultObj.item,
  199. matches: resultObj.matches,
  200. highlighted: index === highlightedIndex,
  201. itemProps: getItemProps({
  202. item: resultObj.item,
  203. index,
  204. }),
  205. }),
  206. {key: `${resultObj.item.title}-${index}`}
  207. );
  208. })
  209. )}
  210. {!isLoading && props.resultFooter ? (
  211. <ResultFooter>{props.resultFooter}</ResultFooter>
  212. ) : null}
  213. </DropdownBox>
  214. )}
  215. </SearchSources>
  216. ) : null}
  217. </SearchWrapper>
  218. );
  219. }}
  220. </AutoComplete>
  221. );
  222. }
  223. const WithRouterSearch = withRouter(Search);
  224. export {WithRouterSearch as Search, SearchProps};
  225. const DropdownBox = styled('div')`
  226. background: ${p => p.theme.background};
  227. border: 1px solid ${p => p.theme.border};
  228. box-shadow: ${p => p.theme.dropShadowHeavy};
  229. position: absolute;
  230. top: 36px;
  231. right: 0;
  232. width: 400px;
  233. border-radius: 5px;
  234. overflow: auto;
  235. max-height: 60vh;
  236. `;
  237. const SearchWrapper = styled('div')`
  238. position: relative;
  239. `;
  240. const ResultFooter = styled('div')`
  241. position: sticky;
  242. bottom: 0;
  243. left: 0;
  244. right: 0;
  245. `;
  246. const EmptyItem = styled(SearchResultWrapper)`
  247. text-align: center;
  248. padding: 16px;
  249. opacity: 0.5;
  250. `;
  251. const LoadingWrapper = styled('div')`
  252. display: flex;
  253. justify-content: center;
  254. align-items: center;
  255. padding: ${space(1)};
  256. `;