traceSearchInput.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import type React from 'react';
  2. import {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 {TraceSearchState} from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearch';
  11. interface TraceSearchInputProps {
  12. onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  13. onKeyDown: React.KeyboardEventHandler<HTMLInputElement>;
  14. onNextSearchClick: () => void;
  15. onPreviousSearchClick: () => void;
  16. onSearchClear: () => void;
  17. query: string | undefined;
  18. resultCount: number | undefined;
  19. resultIteratorIndex: number | null;
  20. status: TraceSearchState['status'];
  21. }
  22. const MIN_LOADING_TIME = 300;
  23. export function TraceSearchInput(props: TraceSearchInputProps) {
  24. const [status, setStatus] = useState<TraceSearchState['status']>();
  25. const timeoutRef = useRef<number | undefined>(undefined);
  26. const statusRef = useRef<TraceSearchState['status']>(status);
  27. statusRef.current = status;
  28. useLayoutEffect(() => {
  29. if (typeof timeoutRef.current === 'number') {
  30. window.clearTimeout(timeoutRef.current);
  31. }
  32. // if status is loading, show loading icon immediately
  33. // if previous status was loading, show loading icon for at least 500ms
  34. if (!statusRef.current && props.status) {
  35. setStatus([performance.now(), props.status[1]]);
  36. return;
  37. }
  38. const nextStatus = props.status;
  39. if (nextStatus) {
  40. const elapsed = performance.now() - nextStatus[0];
  41. if (elapsed > MIN_LOADING_TIME || nextStatus[1] === 'loading') {
  42. setStatus(nextStatus);
  43. return;
  44. }
  45. const schedule = nextStatus[0] + MIN_LOADING_TIME - performance.now();
  46. timeoutRef.current = window.setTimeout(() => {
  47. setStatus(nextStatus);
  48. }, schedule);
  49. } else {
  50. setStatus(nextStatus);
  51. }
  52. }, [props.status]);
  53. return (
  54. <StyledSearchBar>
  55. <InputGroup.LeadingItems disablePointerEvents>
  56. <InvisiblePlaceholder />
  57. {status?.[1] === 'loading' ? (
  58. <StyledLoadingIndicator size={12} />
  59. ) : (
  60. <StyledSearchIcon color="subText" size={'xs'} />
  61. )}
  62. </InputGroup.LeadingItems>
  63. <InputGroup.Input
  64. size="xs"
  65. type="text"
  66. name="query"
  67. autoComplete="off"
  68. placeholder={t('Search in trace')}
  69. value={props.query}
  70. onChange={props.onChange}
  71. onKeyDown={props.onKeyDown}
  72. />
  73. <InputGroup.TrailingItems>
  74. <StyledTrailingText>
  75. {`${
  76. props.query && !props.resultCount
  77. ? t('no results')
  78. : props.query
  79. ? (props.resultIteratorIndex !== null
  80. ? props.resultIteratorIndex + 1
  81. : '-') + `/${props.resultCount ?? 0}`
  82. : ''
  83. }`}
  84. </StyledTrailingText>
  85. <StyledSearchBarTrailingButton
  86. size="zero"
  87. borderless
  88. icon={<IconChevron size="xs" />}
  89. aria-label={t('Next')}
  90. disabled={status?.[1] === 'loading'}
  91. onClick={props.onPreviousSearchClick}
  92. />
  93. <StyledSearchBarTrailingButton
  94. size="zero"
  95. borderless
  96. icon={<IconChevron size="xs" direction="down" />}
  97. aria-label={t('Previous')}
  98. disabled={status?.[1] === 'loading'}
  99. onClick={props.onNextSearchClick}
  100. />
  101. {props.query ? (
  102. <SearchBarTrailingButton
  103. size="zero"
  104. borderless
  105. disabled={status?.[1] === 'loading'}
  106. onClick={props.onSearchClear}
  107. icon={<IconClose size="xs" />}
  108. aria-label={t('Clear')}
  109. />
  110. ) : null}
  111. </InputGroup.TrailingItems>
  112. </StyledSearchBar>
  113. );
  114. }
  115. const InvisiblePlaceholder = styled('div')`
  116. pointer-events: none;
  117. visibility: hidden;
  118. width: 12px;
  119. height: 12px;
  120. `;
  121. const StyledLoadingIndicator = styled(LoadingIndicator)`
  122. margin: 0;
  123. left: 0;
  124. top: 50%;
  125. position: absolute;
  126. transform: translate(-2px, -50%);
  127. animation: showLoadingIndicator 0.3s ease-in-out forwards;
  128. @keyframes showLoadingIndicator {
  129. from {
  130. opacity: 0;
  131. transform: translate(-2px, -50%) scale(0.86);
  132. }
  133. to {
  134. opacity: 1;
  135. transform: translate(-2px, -50%) scale(1);
  136. }
  137. }
  138. .loading-indicator {
  139. border-width: 2px;
  140. }
  141. .loading-message {
  142. display: none;
  143. }
  144. `;
  145. const StyledSearchIcon = styled(IconSearch)`
  146. position: absolute;
  147. left: 0;
  148. top: 50%;
  149. transform: scale(1) translateY(-50%);
  150. animation: showSearchIcon 0.3s ease-in-out forwards;
  151. @keyframes showSearchIcon {
  152. from {
  153. opacity: 0;
  154. transform: scale(0.86) translateY(-50%);
  155. }
  156. to {
  157. opacity: 1;
  158. transform: scale(1) translateY(-50%);
  159. }
  160. }
  161. `;
  162. const StyledSearchBarTrailingButton = styled(SearchBarTrailingButton)`
  163. padding: 0;
  164. `;
  165. const StyledTrailingText = styled('span')`
  166. color: ${p => p.theme.subText};
  167. font-size: ${p => p.theme.fontSizeSmall};
  168. `;
  169. const StyledSearchBar = styled(InputGroup)`
  170. flex: 1 1 100%;
  171. margin-bottom: ${space(1)};
  172. > div > div:last-child {
  173. gap: ${space(0.25)};
  174. }
  175. `;