searchBar.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import {useCallback, useState} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import BaseSearchBar from 'sentry/components/searchBar';
  6. import {getSearchGroupWithItemMarkedActive} from 'sentry/components/smartSearchBar/utils';
  7. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  8. import {t} from 'sentry/locale';
  9. import {Organization} from 'sentry/types';
  10. import EventView from 'sentry/utils/discover/eventView';
  11. import {doDiscoverQuery} from 'sentry/utils/discover/genericDiscoverQuery';
  12. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  13. import useApi from 'sentry/utils/useApi';
  14. import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
  15. import SearchDropdown from '../smartSearchBar/searchDropdown';
  16. import {ItemType, SearchGroup} from '../smartSearchBar/types';
  17. export type SearchBarProps = {
  18. eventView: EventView;
  19. onSearch: (query: string) => void;
  20. organization: Organization;
  21. query: string;
  22. };
  23. function SearchBar(props: SearchBarProps) {
  24. const {organization, eventView: _eventView, onSearch, query: searchQuery} = props;
  25. const [searchResults, setSearchResults] = useState<SearchGroup[]>([]);
  26. const transactionCount = searchResults[0]?.children?.length || 0;
  27. const [highlightedItemIndex, setHighlightedItemIndex] = useState(-1);
  28. const [isDropdownOpen, setIsDropdownOpen] = useState(false);
  29. const openDropdown = () => setIsDropdownOpen(true);
  30. const closeDropdown = () => setIsDropdownOpen(false);
  31. const [loading, setLoading] = useState(false);
  32. const [searchString, setSearchString] = useState(searchQuery);
  33. const api = useApi();
  34. const eventView = _eventView.clone();
  35. const useEvents = organization.features.includes(
  36. 'performance-frontend-use-events-endpoint'
  37. );
  38. const url = useEvents
  39. ? `/organizations/${organization.slug}/events/`
  40. : `/organizations/${organization.slug}/eventsv2/`;
  41. const projectIdStrings = (eventView.project as Readonly<number>[])?.map(String);
  42. const prepareQuery = (query: string) => {
  43. const prependedChar = query[0] === '*' ? '' : '*';
  44. const appendedChar = query[query.length - 1] === '*' ? '' : '*';
  45. return `${prependedChar}${query}${appendedChar}`;
  46. };
  47. const handleSearchChange = query => {
  48. setSearchString(query);
  49. if (query.length === 0) {
  50. onSearch('');
  51. }
  52. if (query.length < 3) {
  53. setSearchResults([]);
  54. closeDropdown();
  55. return;
  56. }
  57. openDropdown();
  58. getSuggestedTransactions(query);
  59. };
  60. const handleKeyDown = (event: React.KeyboardEvent) => {
  61. const {key} = event;
  62. if (loading) {
  63. return;
  64. }
  65. if (key === 'Escape' && isDropdownOpen) {
  66. closeDropdown();
  67. return;
  68. }
  69. if (
  70. (key === 'ArrowUp' || key === 'ArrowDown') &&
  71. isDropdownOpen &&
  72. transactionCount > 0
  73. ) {
  74. const currentHighlightedItem = searchResults[0].children[highlightedItemIndex];
  75. const nextHighlightedItemIndex =
  76. (highlightedItemIndex + transactionCount + (key === 'ArrowUp' ? -1 : 1)) %
  77. transactionCount;
  78. setHighlightedItemIndex(nextHighlightedItemIndex);
  79. const nextHighlightedItem = searchResults[0].children[nextHighlightedItemIndex];
  80. let newSearchResults = searchResults;
  81. if (currentHighlightedItem) {
  82. newSearchResults = getSearchGroupWithItemMarkedActive(
  83. searchResults,
  84. currentHighlightedItem,
  85. false
  86. );
  87. }
  88. if (nextHighlightedItem) {
  89. newSearchResults = getSearchGroupWithItemMarkedActive(
  90. newSearchResults,
  91. nextHighlightedItem,
  92. true
  93. );
  94. }
  95. setSearchResults(newSearchResults);
  96. return;
  97. }
  98. if (key === 'Enter') {
  99. event.preventDefault();
  100. const currentItem = searchResults[0].children[highlightedItemIndex];
  101. if (!currentItem?.value) {
  102. return;
  103. }
  104. handleSearch(currentItem.value);
  105. return;
  106. }
  107. };
  108. // eslint-disable-next-line react-hooks/exhaustive-deps
  109. const getSuggestedTransactions = useCallback(
  110. debounce(
  111. async query => {
  112. try {
  113. setLoading(true);
  114. const conditions = new MutableSearch('');
  115. conditions.addFilterValues('transaction', [prepareQuery(query)], false);
  116. conditions.addFilterValues('event.type', ['transaction']);
  117. // clear any active requests
  118. if (Object.keys(api.activeRequests).length) {
  119. api.clear();
  120. }
  121. const [results] = await doDiscoverQuery<{
  122. data: Array<{'count()': number; project_id: number; transaction: string}>;
  123. }>(api, url, {
  124. field: ['transaction', 'project_id', 'count()'],
  125. project: projectIdStrings,
  126. sort: '-count()',
  127. query: conditions.formatString(),
  128. statsPeriod: eventView.statsPeriod,
  129. referrer: 'api.performance.transaction-name-search-bar',
  130. });
  131. const parsedResults = results.data.reduce(
  132. (searchGroup: SearchGroup, item) => {
  133. searchGroup.children.push({
  134. value: `${item.transaction}:${item.project_id}`,
  135. title: item.transaction,
  136. type: ItemType.LINK,
  137. desc: '',
  138. });
  139. return searchGroup;
  140. },
  141. {
  142. title: 'All Transactions',
  143. children: [],
  144. icon: null,
  145. type: 'header',
  146. }
  147. );
  148. setHighlightedItemIndex(-1);
  149. setSearchResults([parsedResults]);
  150. } catch (_) {
  151. throw new Error('Unable to fetch event field values');
  152. } finally {
  153. setLoading(false);
  154. }
  155. },
  156. DEFAULT_DEBOUNCE_DURATION,
  157. {leading: true}
  158. ),
  159. [api, url, eventView.statsPeriod, projectIdStrings.join(',')]
  160. );
  161. const handleSearch = (query: string) => {
  162. const lastIndex = query.lastIndexOf(':');
  163. const transactionName = query.slice(0, lastIndex);
  164. setSearchResults([]);
  165. setSearchString(transactionName);
  166. onSearch(`transaction:${transactionName}`);
  167. closeDropdown();
  168. };
  169. const navigateToTransactionSummary = (name: string) => {
  170. const lastIndex = name.lastIndexOf(':');
  171. const transactionName = name.slice(0, lastIndex);
  172. const projectId = name.slice(lastIndex + 1);
  173. const query = eventView.generateQueryStringObject();
  174. setSearchResults([]);
  175. const next = transactionSummaryRouteWithQuery({
  176. orgSlug: organization.slug,
  177. transaction: String(transactionName),
  178. projectID: projectId,
  179. query,
  180. });
  181. browserHistory.push(next);
  182. };
  183. return (
  184. <Container data-test-id="transaction-search-bar">
  185. <BaseSearchBar
  186. placeholder={t('Search Transactions')}
  187. onChange={handleSearchChange}
  188. onKeyDown={handleKeyDown}
  189. query={searchString}
  190. />
  191. {isDropdownOpen && (
  192. <SearchDropdown
  193. maxMenuHeight={300}
  194. searchSubstring={searchString}
  195. loading={loading}
  196. items={searchResults}
  197. onClick={handleSearch}
  198. onIconClick={navigateToTransactionSummary}
  199. />
  200. )}
  201. </Container>
  202. );
  203. }
  204. const Container = styled('div')`
  205. position: relative;
  206. `;
  207. export default SearchBar;