index.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import {useCallback, useEffect, useMemo} from 'react';
  2. // eslint-disable-next-line no-restricted-imports
  3. import {withRouter, WithRouterProps} from 'react-router';
  4. import styled from '@emotion/styled';
  5. import debounce from 'lodash/debounce';
  6. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  7. import {navigateTo} from 'sentry/actionCreators/navigation';
  8. import AutoComplete from 'sentry/components/autoComplete';
  9. import SearchSources from 'sentry/components/search/sources';
  10. import ApiSource from 'sentry/components/search/sources/apiSource';
  11. import CommandSource from 'sentry/components/search/sources/commandSource';
  12. import FormSource from 'sentry/components/search/sources/formSource';
  13. import RouteSource from 'sentry/components/search/sources/routeSource';
  14. import {t} from 'sentry/locale';
  15. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  16. import type {Fuse} from 'sentry/utils/fuzzySearch';
  17. import replaceRouterParams from 'sentry/utils/replaceRouterParams';
  18. import {Result} from './sources/types';
  19. import List from './list';
  20. type AutoCompleteOpts = Parameters<AutoComplete<Result['item']>['props']['children']>[0];
  21. type ListProps = React.ComponentProps<typeof List>;
  22. interface InputProps extends Pick<AutoCompleteOpts, 'getInputProps'> {}
  23. interface SearchProps extends WithRouterProps<{orgId: string}> {
  24. /**
  25. * For analytics
  26. */
  27. entryPoint: 'settings_search' | 'command_palette' | 'sidebar_help';
  28. /**
  29. * Minimum number of characters before search activates
  30. */
  31. minSearch: number;
  32. /**
  33. * Render prop for the main input for the search
  34. */
  35. renderInput: (props: InputProps) => React.ReactNode;
  36. /**
  37. * Passed to the underlying AutoComplete component
  38. */
  39. closeOnSelect?: boolean;
  40. /**
  41. * Additional CSS for the dropdown menu.
  42. */
  43. dropdownClassName?: string;
  44. /**
  45. * Maximum number of results to display
  46. */
  47. maxResults?: number;
  48. /**
  49. * Renders the result item
  50. */
  51. renderItem?: ListProps['renderItem'];
  52. /**
  53. * Adds a footer below the results when the search is complete
  54. */
  55. resultFooter?: React.ReactNode;
  56. /**
  57. * Fuse search options
  58. */
  59. searchOptions?: Fuse.IFuseOptions<any>;
  60. /**
  61. * The sources to query
  62. */
  63. // TODO(ts): Improve any type here
  64. sources?: React.ComponentType<any>[];
  65. }
  66. function Search({
  67. entryPoint,
  68. maxResults,
  69. minSearch,
  70. renderInput,
  71. renderItem,
  72. closeOnSelect,
  73. dropdownClassName,
  74. resultFooter,
  75. searchOptions,
  76. sources,
  77. router,
  78. params,
  79. }: SearchProps): React.ReactElement {
  80. useEffect(() => {
  81. trackAdvancedAnalyticsEvent(`${entryPoint}.open`, {
  82. organization: null,
  83. });
  84. }, [entryPoint]);
  85. const handleSelectItem = useCallback(
  86. (item: Result['item'], state?: AutoComplete<Result['item']>['state']) => {
  87. if (!item) {
  88. return;
  89. }
  90. trackAdvancedAnalyticsEvent(`${entryPoint}.select`, {
  91. query: state?.inputValue,
  92. result_type: item.resultType,
  93. source_type: item.sourceType,
  94. organization: null,
  95. });
  96. // `action` refers to a callback function while
  97. // `to` is a react-router route
  98. if (typeof item.action === 'function') {
  99. item.action(item, state);
  100. return;
  101. }
  102. if (!item.to) {
  103. return;
  104. }
  105. if (item.to.startsWith('http')) {
  106. const open = window.open();
  107. if (open) {
  108. open.opener = null;
  109. open.location.href = item.to;
  110. return;
  111. }
  112. addErrorMessage(
  113. t('Unable to open search result (a popup blocker may have caused this).')
  114. );
  115. return;
  116. }
  117. const nextPath = replaceRouterParams(item.to, params);
  118. navigateTo(nextPath, router, item.configUrl);
  119. },
  120. [entryPoint, router, params]
  121. );
  122. const saveQueryMetrics = useCallback(
  123. (query: string) => {
  124. if (!query) {
  125. return;
  126. }
  127. trackAdvancedAnalyticsEvent(`${entryPoint}.query`, {
  128. query,
  129. organization: null,
  130. });
  131. },
  132. [entryPoint]
  133. );
  134. const debouncedSaveQueryMetrics = useMemo(
  135. () => debounce(saveQueryMetrics, 200),
  136. [saveQueryMetrics]
  137. );
  138. return (
  139. <AutoComplete
  140. defaultHighlightedIndex={0}
  141. onSelect={handleSelectItem}
  142. closeOnSelect={closeOnSelect ?? true}
  143. >
  144. {({getInputProps, isOpen, inputValue, ...autocompleteProps}) => {
  145. const searchQuery = inputValue.toLowerCase().trim();
  146. const isValidSearch = inputValue.length >= minSearch;
  147. debouncedSaveQueryMetrics(searchQuery);
  148. return (
  149. <SearchWrapper role="search">
  150. {renderInput({getInputProps})}
  151. {isValidSearch && isOpen ? (
  152. <SearchSources
  153. searchOptions={searchOptions}
  154. query={searchQuery}
  155. params={params}
  156. sources={
  157. sources ??
  158. ([
  159. ApiSource,
  160. FormSource,
  161. RouteSource,
  162. CommandSource,
  163. ] as React.ComponentType[])
  164. }
  165. >
  166. {({isLoading, results, hasAnyResults}) => (
  167. <List
  168. {...{
  169. isLoading,
  170. results,
  171. hasAnyResults,
  172. maxResults,
  173. resultFooter,
  174. dropdownClassName,
  175. renderItem,
  176. ...autocompleteProps,
  177. }}
  178. />
  179. )}
  180. </SearchSources>
  181. ) : null}
  182. </SearchWrapper>
  183. );
  184. }}
  185. </AutoComplete>
  186. );
  187. }
  188. const WithRouterSearch = withRouter(Search);
  189. export {WithRouterSearch as Search, SearchProps};
  190. const SearchWrapper = styled('div')`
  191. position: relative;
  192. `;