list.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import {Fragment, memo, useEffect, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import AutoComplete from 'sentry/components/autoComplete';
  4. import LoadingIndicator from 'sentry/components/loadingIndicator';
  5. import {t} from 'sentry/locale';
  6. import space from 'sentry/styles/space';
  7. import {Result} from './sources/types';
  8. import SearchResult from './searchResult';
  9. import SearchResultWrapper from './searchResultWrapper';
  10. type AutoCompleteOpts = Parameters<AutoComplete<Result['item']>['props']['children']>[0];
  11. interface RenderItemProps {
  12. highlighted: boolean;
  13. index: number;
  14. item: Result['item'];
  15. itemProps: ReturnType<AutoCompleteOpts['getItemProps']>;
  16. matches: Result['matches'];
  17. }
  18. type RenderItem = (props: RenderItemProps) => React.ReactNode;
  19. type Props = {
  20. getItemProps: AutoCompleteOpts['getItemProps'];
  21. hasAnyResults: boolean;
  22. highlightedIndex: number;
  23. isLoading: boolean;
  24. registerItemCount: AutoCompleteOpts['registerItemCount'];
  25. registerVisibleItem: AutoCompleteOpts['registerVisibleItem'];
  26. resultFooter: React.ReactNode;
  27. results: Result[];
  28. dropdownClassName?: string;
  29. maxResults?: number;
  30. renderItem?: RenderItem;
  31. };
  32. function defaultItemRenderer({item, highlighted, itemProps, matches}: RenderItemProps) {
  33. return (
  34. <SearchResultWrapper highlighted={highlighted} {...itemProps}>
  35. <SearchResult highlighted={highlighted} item={item} matches={matches} />
  36. </SearchResultWrapper>
  37. );
  38. }
  39. function List({
  40. dropdownClassName,
  41. isLoading,
  42. hasAnyResults,
  43. results,
  44. maxResults,
  45. getItemProps,
  46. highlightedIndex,
  47. resultFooter,
  48. registerItemCount,
  49. registerVisibleItem,
  50. renderItem = defaultItemRenderer,
  51. }: Props) {
  52. const resultList = results.slice(0, maxResults);
  53. useEffect(
  54. () => registerItemCount(resultList.length),
  55. [registerItemCount, resultList.length]
  56. );
  57. return (
  58. <DropdownBox className={dropdownClassName}>
  59. {isLoading ? (
  60. <LoadingWrapper>
  61. <LoadingIndicator mini hideMessage relative />
  62. </LoadingWrapper>
  63. ) : !hasAnyResults ? (
  64. <EmptyItem>{t('No results found')}</EmptyItem>
  65. ) : (
  66. resultList.map((result, index) => {
  67. const {item, matches, refIndex} = result;
  68. const highlighted = index === highlightedIndex;
  69. const resultProps = {
  70. renderItem,
  71. registerVisibleItem,
  72. getItemProps,
  73. highlighted,
  74. index,
  75. item,
  76. matches,
  77. };
  78. return <ResultRow key={`${index}-${refIndex}`} {...resultProps} />;
  79. })
  80. )}
  81. {!isLoading && resultFooter ? <ResultFooter>{resultFooter}</ResultFooter> : null}
  82. </DropdownBox>
  83. );
  84. }
  85. type SearchItemProps = {
  86. getItemProps: Props['getItemProps'];
  87. highlighted: boolean;
  88. index: number;
  89. item: Result['item'];
  90. matches: Result['matches'];
  91. registerVisibleItem: Props['registerVisibleItem'];
  92. renderItem: RenderItem;
  93. };
  94. // XXX(epurkhiser): We memoize the ResultRow component since there will be many
  95. // of them, we do not want them re-rendering every time we change the
  96. // highlightedIndex in the parent List.
  97. /**
  98. * Search item is used to call `registerVisibleItem` any time the item changes
  99. */
  100. const ResultRow = memo(
  101. ({
  102. renderItem,
  103. registerVisibleItem,
  104. getItemProps,
  105. ...renderItemProps
  106. }: SearchItemProps) => {
  107. const {item, index} = renderItemProps;
  108. useEffect(() => registerVisibleItem(index, item), [registerVisibleItem, item]);
  109. const itemProps = useMemo(
  110. () => getItemProps({item, index}),
  111. [getItemProps, item, index]
  112. );
  113. return <Fragment>{renderItem({itemProps, ...renderItemProps})}</Fragment>;
  114. }
  115. );
  116. export default List;
  117. const DropdownBox = styled('div')`
  118. background: ${p => p.theme.background};
  119. border: 1px solid ${p => p.theme.border};
  120. box-shadow: ${p => p.theme.dropShadowHeavy};
  121. position: absolute;
  122. top: 36px;
  123. right: 0;
  124. width: 400px;
  125. border-radius: 5px;
  126. overflow: auto;
  127. max-height: 60vh;
  128. `;
  129. const ResultFooter = styled('div')`
  130. position: sticky;
  131. bottom: 0;
  132. left: 0;
  133. right: 0;
  134. `;
  135. const EmptyItem = styled(SearchResultWrapper)`
  136. text-align: center;
  137. padding: 16px;
  138. opacity: 0.5;
  139. `;
  140. const LoadingWrapper = styled('div')`
  141. display: flex;
  142. justify-content: center;
  143. align-items: center;
  144. padding: ${space(1)};
  145. `;