traceSearchInput.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import type React from 'react';
  2. import {Fragment, useCallback, useLayoutEffect, useRef, useState} from 'react';
  3. import styled from '@emotion/styled';
  4. import {InputGroup} from 'sentry/components/inputGroup';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import {SearchBarTrailingButton} from 'sentry/components/searchBar';
  7. import {IconChevron, IconClose, IconSearch} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import useOrganization from 'sentry/utils/useOrganization';
  11. import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics';
  12. import type {
  13. TraceTree,
  14. TraceTreeNode,
  15. } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
  16. import type {
  17. TraceReducerAction,
  18. TraceReducerState,
  19. } from 'sentry/views/performance/newTraceDetails/traceState';
  20. import type {TraceSearchState} from 'sentry/views/performance/newTraceDetails/traceState/traceSearch';
  21. interface TraceSearchInputProps {
  22. onTraceSearch: (
  23. query: string,
  24. node: TraceTreeNode<TraceTree.NodeValue> | null,
  25. behavior: 'track result' | 'persist'
  26. ) => void;
  27. trace_dispatch: React.Dispatch<TraceReducerAction>;
  28. trace_state: TraceReducerState;
  29. }
  30. const MIN_LOADING_TIME = 300;
  31. export function TraceSearchInput(props: TraceSearchInputProps) {
  32. const organization = useOrganization();
  33. const [status, setStatus] = useState<TraceSearchState['status']>();
  34. const timeoutRef = useRef<number | undefined>(undefined);
  35. const statusRef = useRef<TraceSearchState['status']>(status);
  36. statusRef.current = status;
  37. const traceStateRef = useRef(props.trace_state);
  38. traceStateRef.current = props.trace_state;
  39. const trace_dispatch = props.trace_dispatch;
  40. const onTraceSearch = props.onTraceSearch;
  41. useLayoutEffect(() => {
  42. if (typeof timeoutRef.current === 'number') {
  43. window.clearTimeout(timeoutRef.current);
  44. }
  45. // if status is loading, show loading icon immediately
  46. // if previous status was loading, show loading icon for at least 500ms
  47. if (!statusRef.current && props.trace_state.search.status) {
  48. setStatus([performance.now(), props.trace_state.search.status[1]]);
  49. return;
  50. }
  51. const nextStatus = props.trace_state.search.status;
  52. if (nextStatus) {
  53. const elapsed = performance.now() - nextStatus[0];
  54. if (elapsed > MIN_LOADING_TIME || nextStatus[1] === 'loading') {
  55. setStatus(nextStatus);
  56. return;
  57. }
  58. const schedule = nextStatus[0] + MIN_LOADING_TIME - performance.now();
  59. timeoutRef.current = window.setTimeout(() => {
  60. setStatus(nextStatus);
  61. }, schedule);
  62. } else {
  63. setStatus(nextStatus);
  64. }
  65. }, [props.trace_state.search.status]);
  66. const onSearchFocus = useCallback(() => {
  67. traceAnalytics.trackSearchFocus(organization);
  68. if (traceStateRef.current.rovingTabIndex.node) {
  69. trace_dispatch({type: 'clear roving index'});
  70. }
  71. }, [trace_dispatch, organization]);
  72. const onChange = useCallback(
  73. (event: React.ChangeEvent<HTMLInputElement>) => {
  74. if (!event.target.value) {
  75. trace_dispatch({type: 'clear query'});
  76. return;
  77. }
  78. trace_dispatch({type: 'set query', query: event.target.value});
  79. onTraceSearch(
  80. event.target.value,
  81. traceStateRef.current.rovingTabIndex.node ?? traceStateRef.current.search.node,
  82. 'track result'
  83. );
  84. },
  85. [trace_dispatch, onTraceSearch]
  86. );
  87. const onSearchClear = useCallback(() => {
  88. trace_dispatch({type: 'clear query'});
  89. }, [trace_dispatch]);
  90. const onKeyDown = useCallback(
  91. (event: React.KeyboardEvent<HTMLInputElement>) => {
  92. switch (event.key) {
  93. case 'ArrowDown':
  94. trace_dispatch({
  95. type: event.shiftKey ? 'go to last match' : 'go to next match',
  96. });
  97. break;
  98. case 'ArrowUp':
  99. trace_dispatch({
  100. type: event.shiftKey ? 'go to first match' : 'go to previous match',
  101. });
  102. break;
  103. case 'Enter':
  104. trace_dispatch({
  105. type: event.shiftKey ? 'go to previous match' : 'go to next match',
  106. });
  107. break;
  108. default:
  109. }
  110. },
  111. [trace_dispatch]
  112. );
  113. const onNextSearchClick = useCallback(() => {
  114. if (traceStateRef.current.rovingTabIndex.node) {
  115. trace_dispatch({type: 'clear roving index'});
  116. }
  117. trace_dispatch({type: 'go to next match'});
  118. }, [trace_dispatch]);
  119. const onPreviousSearchClick = useCallback(() => {
  120. if (traceStateRef.current.rovingTabIndex.node) {
  121. trace_dispatch({type: 'clear roving index'});
  122. }
  123. trace_dispatch({type: 'go to previous match'});
  124. }, [trace_dispatch]);
  125. return (
  126. <StyledSearchBar>
  127. <InputGroup.LeadingItems>
  128. <InvisiblePlaceholder />
  129. {status?.[1] === 'loading' ? (
  130. <StyledLoadingIndicator data-test-id="trace-search-loading" size={12} />
  131. ) : (
  132. <StyledSearchIcon
  133. data-test-id="trace-search-success"
  134. color="subText"
  135. size={'xs'}
  136. />
  137. )}
  138. </InputGroup.LeadingItems>
  139. <InputGroup.Input
  140. size="xs"
  141. type="text"
  142. name="query"
  143. autoComplete="off"
  144. placeholder={t('Search in trace')}
  145. defaultValue={props.trace_state.search.query ?? ''}
  146. onChange={onChange}
  147. onKeyDown={onKeyDown}
  148. onFocus={onSearchFocus}
  149. />
  150. <InputGroup.TrailingItems>
  151. <StyledTrailingText data-test-id="trace-search-result-iterator">
  152. {`${
  153. props.trace_state.search.query && !props.trace_state.search.results?.length
  154. ? t('no results')
  155. : props.trace_state.search.query
  156. ? (props.trace_state.search.resultIteratorIndex !== null
  157. ? props.trace_state.search.resultIteratorIndex + 1
  158. : '-') + `/${props.trace_state.search.results?.length ?? 0}`
  159. : ''
  160. }`}
  161. </StyledTrailingText>
  162. {props.trace_state.search.query ? (
  163. <Fragment>
  164. <StyledSearchBarTrailingButton
  165. size="zero"
  166. borderless
  167. icon={<IconChevron size="xs" />}
  168. aria-label={t('Next')}
  169. disabled={status?.[1] === 'loading'}
  170. onClick={onPreviousSearchClick}
  171. />
  172. <StyledSearchBarTrailingButton
  173. size="zero"
  174. borderless
  175. icon={<IconChevron size="xs" direction="down" />}
  176. aria-label={t('Previous')}
  177. disabled={status?.[1] === 'loading'}
  178. onClick={onNextSearchClick}
  179. />
  180. <StyledSearchBarTrailingButton
  181. size="zero"
  182. borderless
  183. disabled={status?.[1] === 'loading'}
  184. onClick={onSearchClear}
  185. icon={<IconClose size="xs" />}
  186. aria-label={t('Clear')}
  187. />
  188. </Fragment>
  189. ) : null}
  190. </InputGroup.TrailingItems>
  191. </StyledSearchBar>
  192. );
  193. }
  194. const InvisiblePlaceholder = styled('div')`
  195. pointer-events: none;
  196. visibility: hidden;
  197. width: 12px;
  198. height: 12px;
  199. `;
  200. const StyledLoadingIndicator = styled(LoadingIndicator)`
  201. margin: 0;
  202. left: 0;
  203. top: 50%;
  204. position: absolute;
  205. transform: translate(-2px, -50%);
  206. animation: showLoadingIndicator 0.3s ease-in-out forwards;
  207. @keyframes showLoadingIndicator {
  208. from {
  209. opacity: 0;
  210. transform: translate(-2px, -50%) scale(0.86);
  211. }
  212. to {
  213. opacity: 1;
  214. transform: translate(-2px, -50%) scale(1);
  215. }
  216. }
  217. .loading-indicator {
  218. border-width: 2px;
  219. }
  220. .loading-message {
  221. display: none;
  222. }
  223. `;
  224. const StyledSearchIcon = styled(IconSearch)`
  225. position: absolute;
  226. left: 0;
  227. top: 50%;
  228. transform: scale(1) translateY(-50%);
  229. animation: showSearchIcon 0.3s ease-in-out forwards;
  230. @keyframes showSearchIcon {
  231. from {
  232. opacity: 0;
  233. transform: scale(0.86) translateY(-50%);
  234. }
  235. to {
  236. opacity: 1;
  237. transform: scale(1) translateY(-50%);
  238. }
  239. }
  240. `;
  241. const StyledSearchBarTrailingButton = styled(SearchBarTrailingButton)`
  242. padding: 0;
  243. &:last-child {
  244. svg {
  245. width: 10px;
  246. height: 10px;
  247. }
  248. }
  249. `;
  250. const StyledTrailingText = styled('span')`
  251. color: ${p => p.theme.subText};
  252. font-size: ${p => p.theme.fontSizeSmall};
  253. `;
  254. const StyledSearchBar = styled(InputGroup)`
  255. flex: 1 1 100%;
  256. margin-bottom: ${space(1)};
  257. > div > div:last-child {
  258. gap: ${space(0.25)};
  259. }
  260. `;