traceSearchInput.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import type React from 'react';
  2. import {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 onChange = useCallback(
  64. (event: React.ChangeEvent<HTMLInputElement>) => {
  65. if (!event.target.value) {
  66. trace_dispatch({type: 'clear query'});
  67. return;
  68. }
  69. trace_dispatch({type: 'set query', query: event.target.value});
  70. onTraceSearch(
  71. event.target.value,
  72. traceStateRef.current.rovingTabIndex.node ?? traceStateRef.current.search.node,
  73. 'track result'
  74. );
  75. },
  76. [trace_dispatch, onTraceSearch]
  77. );
  78. const onSearchClear = useCallback(() => {
  79. trace_dispatch({type: 'clear query'});
  80. }, [trace_dispatch]);
  81. const onKeyDown = useCallback(
  82. (event: React.KeyboardEvent<HTMLInputElement>) => {
  83. switch (event.key) {
  84. case 'ArrowDown':
  85. trace_dispatch({
  86. type: event.shiftKey ? 'go to last match' : 'go to next match',
  87. });
  88. break;
  89. case 'ArrowUp':
  90. trace_dispatch({
  91. type: event.shiftKey ? 'go to first match' : 'go to previous match',
  92. });
  93. break;
  94. case 'Enter':
  95. trace_dispatch({
  96. type: event.shiftKey ? 'go to previous match' : 'go to next match',
  97. });
  98. break;
  99. default:
  100. }
  101. },
  102. [trace_dispatch]
  103. );
  104. const onNextSearchClick = useCallback(() => {
  105. trace_dispatch({type: 'go to next match'});
  106. }, [trace_dispatch]);
  107. const onPreviousSearchClick = useCallback(() => {
  108. trace_dispatch({type: 'go to previous match'});
  109. }, [trace_dispatch]);
  110. return (
  111. <StyledSearchBar>
  112. <InputGroup.LeadingItems>
  113. <InvisiblePlaceholder />
  114. {status?.[1] === 'loading' ? (
  115. <StyledLoadingIndicator data-test-id="trace-search-loading" size={12} />
  116. ) : (
  117. <StyledSearchIcon
  118. data-test-id="trace-search-success"
  119. color="subText"
  120. size={'xs'}
  121. />
  122. )}
  123. </InputGroup.LeadingItems>
  124. <InputGroup.Input
  125. size="xs"
  126. type="text"
  127. name="query"
  128. autoComplete="off"
  129. placeholder={t('Search in trace')}
  130. value={props.trace_state.search.query ?? ''}
  131. onChange={onChange}
  132. onKeyDown={onKeyDown}
  133. />
  134. <InputGroup.TrailingItems>
  135. <StyledTrailingText data-test-id="trace-search-result-iterator">
  136. {`${
  137. props.trace_state.search.query && !props.trace_state.search.results?.length
  138. ? t('no results')
  139. : props.trace_state.search.query
  140. ? (props.trace_state.search.resultIteratorIndex !== null
  141. ? props.trace_state.search.resultIteratorIndex + 1
  142. : '-') + `/${props.trace_state.search.results?.length ?? 0}`
  143. : ''
  144. }`}
  145. </StyledTrailingText>
  146. <StyledSearchBarTrailingButton
  147. size="zero"
  148. borderless
  149. icon={<IconChevron size="xs" />}
  150. aria-label={t('Next')}
  151. disabled={status?.[1] === 'loading'}
  152. onClick={onPreviousSearchClick}
  153. />
  154. <StyledSearchBarTrailingButton
  155. size="zero"
  156. borderless
  157. icon={<IconChevron size="xs" direction="down" />}
  158. aria-label={t('Previous')}
  159. disabled={status?.[1] === 'loading'}
  160. onClick={onNextSearchClick}
  161. />
  162. {props.trace_state.search.query ? (
  163. <SearchBarTrailingButton
  164. size="zero"
  165. borderless
  166. disabled={status?.[1] === 'loading'}
  167. onClick={onSearchClear}
  168. icon={<IconClose size="xs" />}
  169. aria-label={t('Clear')}
  170. />
  171. ) : null}
  172. </InputGroup.TrailingItems>
  173. </StyledSearchBar>
  174. );
  175. }
  176. const InvisiblePlaceholder = styled('div')`
  177. pointer-events: none;
  178. visibility: hidden;
  179. width: 12px;
  180. height: 12px;
  181. `;
  182. const StyledLoadingIndicator = styled(LoadingIndicator)`
  183. margin: 0;
  184. left: 0;
  185. top: 50%;
  186. position: absolute;
  187. transform: translate(-2px, -50%);
  188. animation: showLoadingIndicator 0.3s ease-in-out forwards;
  189. @keyframes showLoadingIndicator {
  190. from {
  191. opacity: 0;
  192. transform: translate(-2px, -50%) scale(0.86);
  193. }
  194. to {
  195. opacity: 1;
  196. transform: translate(-2px, -50%) scale(1);
  197. }
  198. }
  199. .loading-indicator {
  200. border-width: 2px;
  201. }
  202. .loading-message {
  203. display: none;
  204. }
  205. `;
  206. const StyledSearchIcon = styled(IconSearch)`
  207. position: absolute;
  208. left: 0;
  209. top: 50%;
  210. transform: scale(1) translateY(-50%);
  211. animation: showSearchIcon 0.3s ease-in-out forwards;
  212. @keyframes showSearchIcon {
  213. from {
  214. opacity: 0;
  215. transform: scale(0.86) translateY(-50%);
  216. }
  217. to {
  218. opacity: 1;
  219. transform: scale(1) translateY(-50%);
  220. }
  221. }
  222. `;
  223. const StyledSearchBarTrailingButton = styled(SearchBarTrailingButton)`
  224. padding: 0;
  225. `;
  226. const StyledTrailingText = styled('span')`
  227. color: ${p => p.theme.subText};
  228. font-size: ${p => p.theme.fontSizeSmall};
  229. `;
  230. const StyledSearchBar = styled(InputGroup)`
  231. flex: 1 1 100%;
  232. margin-bottom: ${space(1)};
  233. > div > div:last-child {
  234. gap: ${space(0.25)};
  235. }
  236. `;