traceSearchInput.tsx 5.1 KB

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