traceSearchInput.tsx 8.1 KB

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