index.tsx 8.2 KB

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