traceSearchInput.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  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';
  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. ? '0/0'
  78. : (props.resultIteratorIndex !== null
  79. ? props.resultIteratorIndex + 1
  80. : '-') + `/${props.resultCount ?? 0}`
  81. }`}
  82. </StyledTrailingText>
  83. <StyledSearchBarTrailingButton
  84. size="zero"
  85. borderless
  86. icon={<IconChevron size="xs" />}
  87. aria-label={t('Next')}
  88. disabled={status?.[1] === 'loading'}
  89. onClick={props.onPreviousSearchClick}
  90. />
  91. <StyledSearchBarTrailingButton
  92. size="zero"
  93. borderless
  94. icon={<IconChevron size="xs" direction="down" />}
  95. aria-label={t('Previous')}
  96. disabled={status?.[1] === 'loading'}
  97. onClick={props.onNextSearchClick}
  98. />
  99. {props.query ? (
  100. <SearchBarTrailingButton
  101. size="zero"
  102. borderless
  103. disabled={status?.[1] === 'loading'}
  104. onClick={props.onSearchClear}
  105. icon={<IconClose size="xs" />}
  106. aria-label={t('Clear')}
  107. />
  108. ) : null}
  109. </InputGroup.TrailingItems>
  110. </StyledSearchBar>
  111. );
  112. }
  113. const InvisiblePlaceholder = styled('div')`
  114. pointer-events: none;
  115. visibility: hidden;
  116. width: 12px;
  117. height: 12px;
  118. `;
  119. const StyledLoadingIndicator = styled(LoadingIndicator)`
  120. margin: 0;
  121. left: 0;
  122. top: 50%;
  123. position: absolute;
  124. transform: translate(-2px, -50%);
  125. animation: showLoadingIndicator 0.3s ease-in-out forwards;
  126. @keyframes showLoadingIndicator {
  127. from {
  128. opacity: 0;
  129. transform: translate(-2px, -50%) scale(0.86);
  130. }
  131. to {
  132. opacity: 1;
  133. transform: translate(-2px, -50%) scale(1);
  134. }
  135. }
  136. .loading-indicator {
  137. border-width: 2px;
  138. }
  139. .loading-message {
  140. display: none;
  141. }
  142. `;
  143. const StyledSearchIcon = styled(IconSearch)`
  144. position: absolute;
  145. left: 0;
  146. top: 50%;
  147. transform: scale(1) translateY(-50%);
  148. animation: showSearchIcon 0.3s ease-in-out forwards;
  149. @keyframes showSearchIcon {
  150. from {
  151. opacity: 0;
  152. transform: scale(0.86) translateY(-50%);
  153. }
  154. to {
  155. opacity: 1;
  156. transform: scale(1) translateY(-50%);
  157. }
  158. }
  159. `;
  160. const StyledSearchBarTrailingButton = styled(SearchBarTrailingButton)`
  161. padding: 0;
  162. `;
  163. const StyledTrailingText = styled('span')`
  164. color: ${p => p.theme.subText};
  165. font-size: ${p => p.theme.fontSizeSmall};
  166. `;
  167. const StyledSearchBar = styled(InputGroup)`
  168. flex: 1 1 100%;
  169. margin-bottom: ${space(1)};
  170. > div > div:last-child {
  171. gap: ${space(0.25)};
  172. }
  173. `;