searchBar.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import {useCallback, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import BaseSearchBar from 'sentry/components/searchBar';
  5. import {getSearchGroupWithItemMarkedActive} from 'sentry/components/smartSearchBar/utils';
  6. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  7. import {t} from 'sentry/locale';
  8. import type {Organization} from 'sentry/types/organization';
  9. import {trackAnalytics} from 'sentry/utils/analytics';
  10. import {browserHistory} from 'sentry/utils/browserHistory';
  11. import {parsePeriodToHours} from 'sentry/utils/dates';
  12. import type EventView from 'sentry/utils/discover/eventView';
  13. import {doDiscoverQuery} from 'sentry/utils/discover/genericDiscoverQuery';
  14. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  15. import useApi from 'sentry/utils/useApi';
  16. import useOnClickOutside from 'sentry/utils/useOnClickOutside';
  17. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  18. import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
  19. import SearchDropdown from '../smartSearchBar/searchDropdown';
  20. import type {SearchGroup} from '../smartSearchBar/types';
  21. import {ItemType} from '../smartSearchBar/types';
  22. const TRANSACTION_SEARCH_PERIOD = '14d';
  23. export type SearchBarProps = {
  24. eventView: EventView;
  25. onSearch: (query: string) => void;
  26. organization: Organization;
  27. query: string;
  28. additionalConditions?: MutableSearch;
  29. className?: string;
  30. placeholder?: string;
  31. };
  32. function SearchBar(props: SearchBarProps) {
  33. const {
  34. organization,
  35. eventView: _eventView,
  36. onSearch,
  37. query: searchQuery,
  38. className,
  39. placeholder,
  40. additionalConditions,
  41. } = props;
  42. const [searchResults, setSearchResults] = useState<SearchGroup[]>([]);
  43. const transactionCount = searchResults[0]?.children?.length || 0;
  44. const [highlightedItemIndex, setHighlightedItemIndex] = useState(-1);
  45. const [isDropdownOpen, setIsDropdownOpen] = useState(false);
  46. const openDropdown = () => setIsDropdownOpen(true);
  47. const closeDropdown = () => setIsDropdownOpen(false);
  48. const [loading, setLoading] = useState(false);
  49. const [searchString, setSearchString] = useState(searchQuery);
  50. const containerRef = useRef<HTMLDivElement>(null);
  51. useOnClickOutside(containerRef, useCallback(closeDropdown, []));
  52. const api = useApi();
  53. const eventView = _eventView.clone();
  54. const url = `/organizations/${organization.slug}/events/`;
  55. const projectIdStrings = (eventView.project as Readonly<number>[])?.map(String);
  56. const handleSearchChange = query => {
  57. setSearchString(query);
  58. if (query.length === 0) {
  59. onSearch('');
  60. }
  61. if (query.length < 3) {
  62. setSearchResults([]);
  63. closeDropdown();
  64. return;
  65. }
  66. openDropdown();
  67. getSuggestedTransactions(query);
  68. };
  69. const handleKeyDown = (event: React.KeyboardEvent) => {
  70. const {key} = event;
  71. if (loading) {
  72. return;
  73. }
  74. if (key === 'Escape' && isDropdownOpen) {
  75. closeDropdown();
  76. return;
  77. }
  78. if (
  79. (key === 'ArrowUp' || key === 'ArrowDown') &&
  80. isDropdownOpen &&
  81. transactionCount > 0
  82. ) {
  83. const currentHighlightedItem = searchResults[0].children[highlightedItemIndex];
  84. const nextHighlightedItemIndex =
  85. (highlightedItemIndex + transactionCount + (key === 'ArrowUp' ? -1 : 1)) %
  86. transactionCount;
  87. setHighlightedItemIndex(nextHighlightedItemIndex);
  88. const nextHighlightedItem = searchResults[0].children[nextHighlightedItemIndex];
  89. let newSearchResults = searchResults;
  90. if (currentHighlightedItem) {
  91. newSearchResults = getSearchGroupWithItemMarkedActive(
  92. searchResults,
  93. currentHighlightedItem,
  94. false
  95. );
  96. }
  97. if (nextHighlightedItem) {
  98. newSearchResults = getSearchGroupWithItemMarkedActive(
  99. newSearchResults,
  100. nextHighlightedItem,
  101. true
  102. );
  103. }
  104. setSearchResults(newSearchResults);
  105. return;
  106. }
  107. if (key === 'Enter') {
  108. event.preventDefault();
  109. const currentItem = searchResults[0]?.children[highlightedItemIndex];
  110. if (currentItem?.value) {
  111. handleChooseItem(currentItem.value);
  112. } else {
  113. handleSearch(searchString, true);
  114. }
  115. }
  116. };
  117. // eslint-disable-next-line react-hooks/exhaustive-deps
  118. const getSuggestedTransactions = useCallback(
  119. debounce(
  120. async query => {
  121. try {
  122. setLoading(true);
  123. const conditions = additionalConditions?.copy() ?? new MutableSearch('');
  124. conditions.addFilterValues('transaction', [wrapQueryInWildcards(query)], false);
  125. conditions.addFilterValues('event.type', ['transaction']);
  126. // clear any active requests
  127. if (Object.keys(api.activeRequests).length) {
  128. api.clear();
  129. }
  130. const parsedPeriodHours = eventView.statsPeriod
  131. ? parsePeriodToHours(eventView.statsPeriod)
  132. : 0;
  133. const parsedDefaultHours = parsePeriodToHours(TRANSACTION_SEARCH_PERIOD);
  134. const statsPeriod =
  135. parsedDefaultHours > parsedPeriodHours
  136. ? TRANSACTION_SEARCH_PERIOD
  137. : eventView.statsPeriod;
  138. const [results] = await doDiscoverQuery<{
  139. data: DataItem[];
  140. }>(api, url, {
  141. field: ['transaction', 'project_id', 'count()'],
  142. project: projectIdStrings,
  143. sort: '-count()',
  144. query: conditions.formatString(),
  145. statsPeriod,
  146. referrer: 'api.performance.transaction-name-search-bar',
  147. });
  148. const parsedResults = results.data.reduce(
  149. (searchGroup: SearchGroup, item) => {
  150. searchGroup.children.push({
  151. value: encodeItemToValue(item),
  152. title: item.transaction,
  153. type: ItemType.LINK,
  154. desc: '',
  155. });
  156. return searchGroup;
  157. },
  158. {
  159. title: 'All Transactions',
  160. children: [],
  161. icon: null,
  162. type: 'header',
  163. }
  164. );
  165. setHighlightedItemIndex(-1);
  166. setSearchResults([parsedResults]);
  167. } catch (_) {
  168. throw new Error('Unable to fetch event field values');
  169. } finally {
  170. setLoading(false);
  171. }
  172. },
  173. DEFAULT_DEBOUNCE_DURATION,
  174. {leading: true}
  175. ),
  176. [api, url, eventView.statsPeriod, projectIdStrings.join(',')]
  177. );
  178. const handleChooseItem = (value: string) => {
  179. const item = decodeValueToItem(value);
  180. handleSearch(item.transaction, false);
  181. };
  182. const handleClickItemIcon = (value: string) => {
  183. const item = decodeValueToItem(value);
  184. navigateToItemTransactionSummary(item);
  185. };
  186. const handleSearch = (query: string, asRawText: boolean) => {
  187. setSearchResults([]);
  188. setSearchString(query);
  189. query = new MutableSearch(query).formatString();
  190. const fullQuery = asRawText ? query : `transaction:"${query}"`;
  191. onSearch(query ? fullQuery : '');
  192. closeDropdown();
  193. };
  194. const navigateToItemTransactionSummary = (item: DataItem) => {
  195. const {transaction, project_id} = item;
  196. const query = eventView.generateQueryStringObject();
  197. setSearchResults([]);
  198. const next = transactionSummaryRouteWithQuery({
  199. orgSlug: organization.slug,
  200. transaction,
  201. projectID: String(project_id),
  202. query,
  203. });
  204. browserHistory.push(normalizeUrl(next));
  205. };
  206. const logDocsOpenedEvent = () => {
  207. trackAnalytics('search.docs_opened', {
  208. organization,
  209. search_type: 'performance',
  210. search_source: 'performance_landing',
  211. query: props.query,
  212. });
  213. };
  214. return (
  215. <Container
  216. className={className || ''}
  217. data-test-id="transaction-search-bar"
  218. ref={containerRef}
  219. >
  220. <BaseSearchBar
  221. placeholder={placeholder ?? t('Search Transactions')}
  222. onChange={handleSearchChange}
  223. onKeyDown={handleKeyDown}
  224. query={searchString}
  225. />
  226. {isDropdownOpen && (
  227. <SearchDropdown
  228. maxMenuHeight={300}
  229. searchSubstring={searchString}
  230. loading={loading}
  231. items={searchResults}
  232. onClick={handleChooseItem}
  233. onIconClick={handleClickItemIcon}
  234. onDocsOpen={() => logDocsOpenedEvent()}
  235. />
  236. )}
  237. </Container>
  238. );
  239. }
  240. const encodeItemToValue = (item: DataItem) => {
  241. return `${item.transaction}:${item.project_id}`;
  242. };
  243. const decodeValueToItem = (value: string): DataItem => {
  244. const lastIndex = value.lastIndexOf(':');
  245. return {
  246. project_id: parseInt(value.slice(lastIndex + 1), 10),
  247. transaction: value.slice(0, lastIndex),
  248. };
  249. };
  250. interface DataItem {
  251. project_id: number;
  252. transaction: string;
  253. 'count()'?: number;
  254. }
  255. export const wrapQueryInWildcards = (query: string) => {
  256. if (!query.startsWith('*')) {
  257. query = '*' + query;
  258. }
  259. if (!query.endsWith('*')) {
  260. query = query + '*';
  261. }
  262. return query;
  263. };
  264. const Container = styled('div')`
  265. position: relative;
  266. `;
  267. export default SearchBar;