breadcrumbs.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {useCallback, useEffect, useRef, useState} from 'react';
  2. import {
  3. AutoSizer,
  4. CellMeasurer,
  5. CellMeasurerCache,
  6. List,
  7. ListRowProps,
  8. } from 'react-virtualized';
  9. import {css} from '@emotion/react';
  10. import styled from '@emotion/styled';
  11. import {BreadcrumbWithMeta} from 'sentry/components/events/interfaces/breadcrumbs/types';
  12. import {PanelTable} from 'sentry/components/panels';
  13. import {Tooltip} from 'sentry/components/tooltip';
  14. import {IconSort} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {BreadcrumbType} from 'sentry/types/breadcrumbs';
  18. import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
  19. import {Breadcrumb} from './breadcrumb';
  20. const PANEL_MIN_HEIGHT = 200;
  21. const PANEL_INITIAL_HEIGHT = 400;
  22. const cache = new CellMeasurerCache({
  23. fixedWidth: true,
  24. defaultHeight: 38,
  25. });
  26. type Props = Pick<
  27. React.ComponentProps<typeof Breadcrumb>,
  28. 'event' | 'organization' | 'searchTerm' | 'relativeTime' | 'displayRelativeTime'
  29. > & {
  30. breadcrumbs: BreadcrumbWithMeta[];
  31. emptyMessage: Pick<
  32. React.ComponentProps<typeof PanelTable>,
  33. 'emptyMessage' | 'emptyAction'
  34. >;
  35. onSwitchTimeFormat: () => void;
  36. };
  37. function Breadcrumbs({
  38. breadcrumbs,
  39. displayRelativeTime,
  40. onSwitchTimeFormat,
  41. organization,
  42. searchTerm,
  43. event,
  44. relativeTime,
  45. emptyMessage,
  46. }: Props) {
  47. const [scrollbarSize, setScrollbarSize] = useState(0);
  48. const listRef = useRef<List>(null);
  49. const contentRef = useRef<HTMLDivElement>(null);
  50. const updateGrid = useCallback(() => {
  51. if (listRef.current) {
  52. cache.clearAll();
  53. listRef.current.forceUpdateGrid();
  54. }
  55. }, []);
  56. useEffect(() => {
  57. updateGrid();
  58. }, [breadcrumbs, updateGrid]);
  59. const {
  60. size: containerSize,
  61. isHeld,
  62. onMouseDown,
  63. onDoubleClick,
  64. } = useResizableDrawer({
  65. direction: 'down',
  66. onResize: () => void 0,
  67. initialSize: PANEL_INITIAL_HEIGHT,
  68. min: PANEL_MIN_HEIGHT,
  69. });
  70. function renderRow({index, key, parent, style}: ListRowProps) {
  71. const {breadcrumb, meta} = breadcrumbs[index];
  72. const isLastItem = index === breadcrumbs.length - 1;
  73. return (
  74. <CellMeasurer
  75. cache={cache}
  76. columnIndex={0}
  77. key={key}
  78. parent={parent}
  79. rowIndex={index}
  80. >
  81. {({measure}) => (
  82. <BreadcrumbRow style={style} error={breadcrumb.type === BreadcrumbType.ERROR}>
  83. <Breadcrumb
  84. index={index}
  85. cache={cache}
  86. isLastItem={isLastItem}
  87. style={style}
  88. onResize={measure}
  89. organization={organization}
  90. searchTerm={searchTerm}
  91. breadcrumb={breadcrumb}
  92. meta={meta}
  93. event={event}
  94. relativeTime={relativeTime}
  95. displayRelativeTime={displayRelativeTime}
  96. scrollbarSize={
  97. (contentRef?.current?.offsetHeight ?? 0) < containerSize
  98. ? scrollbarSize
  99. : 0
  100. }
  101. />
  102. </BreadcrumbRow>
  103. )}
  104. </CellMeasurer>
  105. );
  106. }
  107. return (
  108. <Wrapper>
  109. <StyledPanelTable
  110. scrollbarSize={scrollbarSize}
  111. headers={[
  112. t('Type'),
  113. t('Category'),
  114. t('Description'),
  115. t('Level'),
  116. <Time key="time" onClick={onSwitchTimeFormat}>
  117. <Tooltip
  118. containerDisplayMode="inline-flex"
  119. title={
  120. displayRelativeTime ? t('Switch to absolute') : t('Switch to relative')
  121. }
  122. >
  123. <StyledIconSort size="xs" rotated />
  124. </Tooltip>
  125. {t('Time')}
  126. </Time>,
  127. // Space for the scrollbar
  128. '',
  129. ]}
  130. isEmpty={!breadcrumbs.length}
  131. {...emptyMessage}
  132. >
  133. <Content ref={contentRef}>
  134. <AutoSizer disableHeight onResize={updateGrid}>
  135. {({width}) => (
  136. <StyledList
  137. ref={listRef}
  138. deferredMeasurementCache={cache}
  139. height={containerSize}
  140. overscanRowCount={5}
  141. rowCount={breadcrumbs.length}
  142. rowHeight={cache.rowHeight}
  143. rowRenderer={renderRow}
  144. width={width}
  145. onScrollbarPresenceChange={({size}) => setScrollbarSize(size)}
  146. />
  147. )}
  148. </AutoSizer>
  149. </Content>
  150. </StyledPanelTable>
  151. <PanelDragHandle
  152. onMouseDown={onMouseDown}
  153. onDoubleClick={onDoubleClick}
  154. className={isHeld ? 'is-held' : undefined}
  155. />
  156. </Wrapper>
  157. );
  158. }
  159. export default Breadcrumbs;
  160. const Wrapper = styled('div')`
  161. position: relative;
  162. `;
  163. const StyledPanelTable = styled(PanelTable)<{scrollbarSize: number}>`
  164. display: grid;
  165. grid-template-columns: 64px 140px 1fr 106px 100px ${p => `${p.scrollbarSize}px`};
  166. > * {
  167. :nth-child(-n + 6) {
  168. border-bottom: 1px solid ${p => p.theme.border};
  169. border-radius: 0;
  170. /* This is to fix a small issue with the border not being fully visible on smaller devices */
  171. margin-bottom: 1px;
  172. /* Type */
  173. :nth-child(6n-5) {
  174. text-align: center;
  175. }
  176. }
  177. /* Scroll bar header */
  178. :nth-child(6) {
  179. padding: 0;
  180. }
  181. /* Content */
  182. :nth-child(n + 7) {
  183. grid-column: 1/-1;
  184. ${p =>
  185. !p.isEmpty &&
  186. `
  187. padding: 0;
  188. `}
  189. }
  190. }
  191. @media (max-width: ${props => props.theme.breakpoints.small}) {
  192. grid-template-columns: 48px 1fr 74px 82px ${p => `${p.scrollbarSize}px`};
  193. > * {
  194. :nth-child(-n + 6) {
  195. /* Type, Category & Level */
  196. :nth-child(6n-5),
  197. :nth-child(6n-4),
  198. :nth-child(6n-2) {
  199. color: transparent;
  200. }
  201. /* Description & Scrollbar */
  202. :nth-child(6n-3) {
  203. display: none;
  204. }
  205. }
  206. }
  207. }
  208. `;
  209. const Time = styled('div')`
  210. display: grid;
  211. grid-template-columns: max-content 1fr;
  212. gap: ${space(1)};
  213. cursor: pointer;
  214. `;
  215. const StyledIconSort = styled(IconSort)`
  216. transition: 0.15s color;
  217. :hover {
  218. color: ${p => p.theme.gray300};
  219. }
  220. `;
  221. const Content = styled('div')`
  222. overflow: hidden;
  223. `;
  224. const PanelDragHandle = styled('div')`
  225. position: absolute;
  226. bottom: -1px;
  227. left: 1px;
  228. right: 1px;
  229. height: 10px;
  230. cursor: ns-resize;
  231. display: flex;
  232. align-items: center;
  233. &::after {
  234. content: '';
  235. height: 5px;
  236. width: 100%;
  237. border-radius: ${p => p.theme.borderRadiusBottom};
  238. transition: background 100ms ease-in-out;
  239. }
  240. &:hover::after,
  241. &.is-held:after {
  242. background: ${p => p.theme.purple300};
  243. }
  244. `;
  245. // XXX(ts): Emotion11 has some trouble with List's defaultProps
  246. //
  247. // It gives the list have a dynamic height; otherwise, in the case of filtered
  248. // options, a list will be displayed with an empty space
  249. const StyledList = styled(List as any)<React.ComponentProps<typeof List>>`
  250. height: auto !important;
  251. max-height: ${p => p.height}px;
  252. outline: none;
  253. `;
  254. const BreadcrumbRow = styled('div')<{error: boolean}>`
  255. :not(:last-child) {
  256. border-bottom: 1px solid ${p => (p.error ? p.theme.red300 : p.theme.innerBorder)};
  257. }
  258. ${p =>
  259. p.error &&
  260. css`
  261. :after {
  262. content: '';
  263. position: absolute;
  264. top: -1px;
  265. left: 0;
  266. height: 1px;
  267. width: 100%;
  268. background: ${p.theme.red300};
  269. }
  270. `}
  271. `;