index.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import {
  3. AutoSizer,
  4. CellMeasurer,
  5. CellMeasurerCache,
  6. List as ReactVirtualizedList,
  7. ListRowProps,
  8. } from 'react-virtualized';
  9. import styled from '@emotion/styled';
  10. import debounce from 'lodash/debounce';
  11. import isEmpty from 'lodash/isEmpty';
  12. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  13. import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon';
  14. import CompactSelect from 'sentry/components/forms/compactSelect';
  15. import HTMLCode from 'sentry/components/htmlCode';
  16. import Placeholder from 'sentry/components/placeholder';
  17. import {getDetails} from 'sentry/components/replays/breadcrumbs/utils';
  18. import PlayerRelativeTime from 'sentry/components/replays/playerRelativeTime';
  19. import SearchBar from 'sentry/components/searchBar';
  20. import {SVGIconProps} from 'sentry/icons/svgIcon';
  21. import {t} from 'sentry/locale';
  22. import space from 'sentry/styles/space';
  23. import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
  24. import useExtractedCrumbHtml, {
  25. Extraction,
  26. } from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
  27. import type ReplayReader from 'sentry/utils/replays/replayReader';
  28. import {getDomMutationsTypes} from 'sentry/views/replays/detail/domMutations/utils';
  29. import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
  30. import {Filters, getFilteredItems} from 'sentry/views/replays/detail/utils';
  31. type Props = {
  32. replay: ReplayReader;
  33. };
  34. // The cache is used to measure the height of each row
  35. const cache = new CellMeasurerCache({
  36. fixedWidth: true,
  37. minHeight: 82,
  38. });
  39. function DomMutations({replay}: Props) {
  40. const {isLoading, actions} = useExtractedCrumbHtml({replay});
  41. const [searchTerm, setSearchTerm] = useState('');
  42. let listRef: ReactVirtualizedList | null = null;
  43. const [filters, setFilters] = useState<Filters<Extraction>>({});
  44. const filteredDomMutations = useMemo(
  45. () =>
  46. getFilteredItems({
  47. items: actions,
  48. filters,
  49. searchTerm,
  50. searchProp: 'html',
  51. }),
  52. [actions, filters, searchTerm]
  53. );
  54. const handleSearch = useMemo(() => debounce(query => setSearchTerm(query), 150), []);
  55. const startTimestampMs = replay.getReplay().startedAt.getTime();
  56. const {handleMouseEnter, handleMouseLeave, handleClick} =
  57. useCrumbHandlers(startTimestampMs);
  58. const handleFilters = useCallback(
  59. (
  60. selectedValues: (string | number)[],
  61. key: string,
  62. filter: (mutation: Extraction) => boolean
  63. ) => {
  64. const filtersCopy = {...filters};
  65. if (selectedValues.length === 0) {
  66. delete filtersCopy[key];
  67. setFilters(filtersCopy);
  68. return;
  69. }
  70. setFilters({
  71. ...filters,
  72. [key]: filter,
  73. });
  74. },
  75. [filters]
  76. );
  77. // Restart cache when filteredDomMutations changes
  78. useEffect(() => {
  79. if (listRef) {
  80. cache.clearAll();
  81. listRef?.forceUpdateGrid();
  82. }
  83. }, [filteredDomMutations, listRef]);
  84. const renderRow = ({index, key, style, parent}: ListRowProps) => {
  85. const mutation = filteredDomMutations[index];
  86. const {html, crumb} = mutation;
  87. const {title} = getDetails(crumb);
  88. return (
  89. <CellMeasurer
  90. cache={cache}
  91. columnIndex={0}
  92. key={key}
  93. parent={parent}
  94. rowIndex={index}
  95. >
  96. <MutationListItem
  97. onMouseEnter={() => handleMouseEnter(crumb)}
  98. onMouseLeave={() => handleMouseLeave(crumb)}
  99. style={style}
  100. >
  101. <IconWrapper color={crumb.color}>
  102. <BreadcrumbIcon type={crumb.type} />
  103. </IconWrapper>
  104. <MutationContent>
  105. <MutationDetailsContainer>
  106. <div>
  107. <TitleContainer>
  108. <Title>{title}</Title>
  109. </TitleContainer>
  110. <MutationMessage>{crumb.message}</MutationMessage>
  111. </div>
  112. <UnstyledButton onClick={() => handleClick(crumb)}>
  113. <PlayerRelativeTime
  114. relativeTimeMs={startTimestampMs}
  115. timestamp={crumb.timestamp}
  116. />
  117. </UnstyledButton>
  118. </MutationDetailsContainer>
  119. <CodeContainer>
  120. <HTMLCode code={html} />
  121. </CodeContainer>
  122. </MutationContent>
  123. </MutationListItem>
  124. </CellMeasurer>
  125. );
  126. };
  127. return (
  128. <MutationContainer>
  129. <MutationFilters>
  130. <CompactSelect
  131. triggerProps={{
  132. prefix: t('Event Type'),
  133. }}
  134. triggerLabel={isEmpty(filters) ? t('Any') : null}
  135. multiple
  136. options={getDomMutationsTypes(actions).map(mutationEventType => ({
  137. value: mutationEventType,
  138. label: mutationEventType,
  139. }))}
  140. size="sm"
  141. onChange={selections => {
  142. const selectedValues = selections.map(selection => selection.value);
  143. handleFilters(selectedValues, 'eventType', (mutation: Extraction) => {
  144. return selectedValues.includes(mutation.crumb.type);
  145. });
  146. }}
  147. />
  148. <SearchBar size="sm" onChange={handleSearch} placeholder={t('Search DOM')} />
  149. </MutationFilters>
  150. {isLoading ? (
  151. <Placeholder height="200px" />
  152. ) : (
  153. <MutationList>
  154. <AutoSizer>
  155. {({width, height}) => (
  156. <ReactVirtualizedList
  157. ref={(el: ReactVirtualizedList | null) => {
  158. listRef = el;
  159. }}
  160. deferredMeasurementCache={cache}
  161. height={height}
  162. overscanRowCount={5}
  163. rowCount={filteredDomMutations.length}
  164. noRowsRenderer={() => (
  165. <EmptyStateWarning withIcon={false} small>
  166. {t('No related DOM Events recorded')}
  167. </EmptyStateWarning>
  168. )}
  169. rowHeight={cache.rowHeight}
  170. rowRenderer={renderRow}
  171. width={width}
  172. />
  173. )}
  174. </AutoSizer>
  175. </MutationList>
  176. )}
  177. </MutationContainer>
  178. );
  179. }
  180. const MutationFilters = styled('div')`
  181. display: grid;
  182. gap: ${space(1)};
  183. grid-template-columns: max-content 1fr;
  184. margin-bottom: ${space(1)};
  185. @media (max-width: ${p => p.theme.breakpoints.small}) {
  186. margin-top: ${space(1)};
  187. }
  188. `;
  189. const MutationContainer = styled(FluidHeight)`
  190. height: 100%;
  191. `;
  192. const MutationList = styled('ul')`
  193. list-style: none;
  194. position: relative;
  195. height: 100%;
  196. overflow: hidden;
  197. border: 1px solid ${p => p.theme.border};
  198. border-radius: ${p => p.theme.borderRadius};
  199. padding-left: 0;
  200. margin-bottom: 0;
  201. `;
  202. const MutationListItem = styled('li')`
  203. display: flex;
  204. gap: ${space(1)};
  205. flex-grow: 1;
  206. padding: ${space(1)} ${space(1.5)};
  207. position: relative;
  208. &:hover {
  209. background-color: ${p => p.theme.backgroundSecondary};
  210. }
  211. /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */
  212. &::after {
  213. content: '';
  214. position: absolute;
  215. left: 23.5px;
  216. top: 0;
  217. width: 1px;
  218. background: ${p => p.theme.gray200};
  219. height: 100%;
  220. }
  221. &:first-of-type::after {
  222. top: ${space(1)};
  223. bottom: 0;
  224. }
  225. &:last-of-type::after {
  226. top: 0;
  227. height: ${space(1)};
  228. }
  229. &:only-of-type::after {
  230. height: 0;
  231. }
  232. `;
  233. const MutationContent = styled('div')`
  234. overflow: hidden;
  235. width: 100%;
  236. display: flex;
  237. flex-direction: column;
  238. gap: ${space(1)};
  239. `;
  240. const MutationDetailsContainer = styled('div')`
  241. display: flex;
  242. justify-content: space-between;
  243. align-items: flex-start;
  244. flex-grow: 1;
  245. `;
  246. /**
  247. * Taken `from events/interfaces/.../breadcrumbs/types`
  248. */
  249. const IconWrapper = styled('div')<Required<Pick<SVGIconProps, 'color'>>>`
  250. display: flex;
  251. align-items: center;
  252. justify-content: center;
  253. width: 24px;
  254. min-width: 24px;
  255. height: 24px;
  256. border-radius: 50%;
  257. color: ${p => p.theme.white};
  258. background: ${p => p.theme[p.color] ?? p.color};
  259. box-shadow: ${p => p.theme.dropShadowLightest};
  260. z-index: 2;
  261. `;
  262. const TitleContainer = styled('div')`
  263. display: flex;
  264. justify-content: space-between;
  265. `;
  266. const Title = styled('span')`
  267. ${p => p.theme.overflowEllipsis};
  268. text-transform: capitalize;
  269. color: ${p => p.theme.gray400};
  270. font-weight: bold;
  271. line-height: ${p => p.theme.text.lineHeightBody};
  272. `;
  273. const UnstyledButton = styled('button')`
  274. background: none;
  275. border: none;
  276. padding: 0;
  277. line-height: 0.75;
  278. `;
  279. const MutationMessage = styled('p')`
  280. color: ${p => p.theme.gray300};
  281. font-size: ${p => p.theme.fontSizeSmall};
  282. margin-bottom: 0;
  283. `;
  284. const CodeContainer = styled('div')`
  285. max-height: 400px;
  286. `;
  287. export default DomMutations;