breadcrumbs.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import {useEffect, useRef, useState} from 'react';
  2. import {
  3. AutoSizer,
  4. CellMeasurer,
  5. CellMeasurerCache,
  6. List,
  7. ListRowProps,
  8. } from 'react-virtualized';
  9. import styled from '@emotion/styled';
  10. import {PanelTable} from 'sentry/components/panels';
  11. import Tooltip from 'sentry/components/tooltip';
  12. import {IconSort} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import space from 'sentry/styles/space';
  15. import {Crumb} from 'sentry/types/breadcrumbs';
  16. import Breadcrumb from './breadcrumb';
  17. const PANEL_MAX_HEIGHT = 400;
  18. const cache = new CellMeasurerCache({
  19. fixedWidth: true,
  20. minHeight: 42,
  21. });
  22. type Props = Pick<
  23. React.ComponentProps<typeof Breadcrumb>,
  24. | 'event'
  25. | 'organization'
  26. | 'searchTerm'
  27. | 'relativeTime'
  28. | 'displayRelativeTime'
  29. | 'router'
  30. | 'route'
  31. > & {
  32. breadcrumbs: Crumb[];
  33. emptyMessage: Pick<
  34. React.ComponentProps<typeof PanelTable>,
  35. 'emptyMessage' | 'emptyAction'
  36. >;
  37. onSwitchTimeFormat: () => void;
  38. };
  39. function Breadcrumbs({
  40. breadcrumbs,
  41. displayRelativeTime,
  42. onSwitchTimeFormat,
  43. organization,
  44. searchTerm,
  45. event,
  46. relativeTime,
  47. emptyMessage,
  48. route,
  49. router,
  50. }: Props) {
  51. const [scrollToIndex, setScrollToIndex] = useState<number | undefined>(undefined);
  52. const [scrollbarSize, setScrollbarSize] = useState(0);
  53. let listRef: List | null = null;
  54. const contentRef = useRef<HTMLDivElement>(null);
  55. useEffect(() => {
  56. updateGrid();
  57. }, []);
  58. useEffect(() => {
  59. if (!!breadcrumbs.length && !scrollToIndex) {
  60. setScrollToIndex(breadcrumbs.length - 1);
  61. return;
  62. }
  63. updateGrid();
  64. }, [breadcrumbs]);
  65. useEffect(() => {
  66. if (scrollToIndex !== undefined) {
  67. updateGrid();
  68. }
  69. }, [scrollToIndex]);
  70. function updateGrid() {
  71. if (listRef) {
  72. cache.clearAll();
  73. listRef.forceUpdateGrid();
  74. }
  75. }
  76. function renderRow({index, key, parent, style}: ListRowProps) {
  77. const breadcrumb = breadcrumbs[index];
  78. const isLastItem = breadcrumbs[breadcrumbs.length - 1].id === breadcrumb.id;
  79. const {height} = style;
  80. return (
  81. <CellMeasurer
  82. cache={cache}
  83. columnIndex={0}
  84. key={key}
  85. parent={parent}
  86. rowIndex={index}
  87. >
  88. {({measure}) => (
  89. <Breadcrumb
  90. data-test-id={isLastItem ? 'last-crumb' : 'crumb'}
  91. style={style}
  92. onLoad={measure}
  93. organization={organization}
  94. searchTerm={searchTerm}
  95. breadcrumb={breadcrumb}
  96. event={event}
  97. relativeTime={relativeTime}
  98. displayRelativeTime={displayRelativeTime}
  99. height={height ? String(height) : undefined}
  100. scrollbarSize={
  101. (contentRef?.current?.offsetHeight ?? 0) < PANEL_MAX_HEIGHT
  102. ? scrollbarSize
  103. : 0
  104. }
  105. router={router}
  106. route={route}
  107. />
  108. )}
  109. </CellMeasurer>
  110. );
  111. }
  112. return (
  113. <StyledPanelTable
  114. scrollbarSize={scrollbarSize}
  115. headers={[
  116. t('Type'),
  117. t('Category'),
  118. t('Description'),
  119. t('Level'),
  120. <Time key="time" onClick={onSwitchTimeFormat}>
  121. <Tooltip
  122. containerDisplayMode="inline-flex"
  123. title={
  124. displayRelativeTime ? t('Switch to absolute') : t('Switch to relative')
  125. }
  126. >
  127. <StyledIconSort size="xs" rotated />
  128. </Tooltip>
  129. {t('Time')}
  130. </Time>,
  131. '',
  132. ]}
  133. isEmpty={!breadcrumbs.length}
  134. {...emptyMessage}
  135. >
  136. <Content ref={contentRef}>
  137. <AutoSizer disableHeight onResize={updateGrid}>
  138. {({width}) => (
  139. <StyledList
  140. ref={(el: List | null) => {
  141. listRef = el;
  142. }}
  143. deferredMeasurementCache={cache}
  144. height={PANEL_MAX_HEIGHT}
  145. overscanRowCount={5}
  146. rowCount={breadcrumbs.length}
  147. rowHeight={cache.rowHeight}
  148. rowRenderer={renderRow}
  149. width={width}
  150. onScrollbarPresenceChange={({size}) => setScrollbarSize(size)}
  151. // when the component mounts, it scrolls to the last item
  152. scrollToIndex={scrollToIndex}
  153. scrollToAlignment={scrollToIndex ? 'end' : undefined}
  154. />
  155. )}
  156. </AutoSizer>
  157. </Content>
  158. </StyledPanelTable>
  159. );
  160. }
  161. export default Breadcrumbs;
  162. const StyledPanelTable = styled(PanelTable)<{scrollbarSize: number}>`
  163. display: grid;
  164. grid-template-columns: 64px 140px 1fr 106px 100px ${p => `${p.scrollbarSize}px`};
  165. > * {
  166. :nth-child(-n + 6) {
  167. border-bottom: 1px solid ${p => p.theme.border};
  168. border-radius: 0;
  169. /* This is to fix a small issue with the border not being fully visible on smaller devices */
  170. margin-bottom: 1px;
  171. /* Type */
  172. :nth-child(6n-5) {
  173. text-align: center;
  174. }
  175. }
  176. /* Content */
  177. :nth-child(n + 7) {
  178. grid-column: 1/-1;
  179. ${p =>
  180. !p.isEmpty &&
  181. `
  182. padding: 0;
  183. `}
  184. }
  185. }
  186. @media (max-width: ${props => props.theme.breakpoints.small}) {
  187. grid-template-columns: 48px 1fr 74px 82px ${p => `${p.scrollbarSize}px`};
  188. > * {
  189. :nth-child(-n + 6) {
  190. /* Type, Category & Level */
  191. :nth-child(6n-5),
  192. :nth-child(6n-4),
  193. :nth-child(6n-2) {
  194. color: transparent;
  195. }
  196. /* Description & Scrollbar */
  197. :nth-child(6n-3) {
  198. display: none;
  199. }
  200. }
  201. }
  202. }
  203. overflow: hidden;
  204. `;
  205. const Time = styled('div')`
  206. display: grid;
  207. grid-template-columns: max-content 1fr;
  208. gap: ${space(1)};
  209. cursor: pointer;
  210. `;
  211. const StyledIconSort = styled(IconSort)`
  212. transition: 0.15s color;
  213. :hover {
  214. color: ${p => p.theme.gray300};
  215. }
  216. `;
  217. const Content = styled('div')`
  218. max-height: ${PANEL_MAX_HEIGHT}px;
  219. overflow: hidden;
  220. `;
  221. // XXX(ts): Emotion11 has some trouble with List's defaultProps
  222. //
  223. // It gives the list have a dynamic height; otherwise, in the case of filtered
  224. // options, a list will be displayed with an empty space
  225. const StyledList = styled(List as any)<React.ComponentProps<typeof List>>`
  226. height: auto !important;
  227. max-height: ${p => p.height}px;
  228. outline: none;
  229. `;