import {useEffect} from 'react'; import { AutoSizer, CellMeasurer, CellMeasurerCache, List as ReactVirtualizedList, ListRowProps, } from 'react-virtualized'; import styled from '@emotion/styled'; import CompactSelect from 'sentry/components/compactSelect'; import EmptyStateWarning from 'sentry/components/emptyStateWarning'; import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon'; import HTMLCode from 'sentry/components/htmlCode'; import Placeholder from 'sentry/components/placeholder'; import {getDetails} from 'sentry/components/replays/breadcrumbs/utils'; import PlayerRelativeTime from 'sentry/components/replays/playerRelativeTime'; import {useReplayContext} from 'sentry/components/replays/replayContext'; import {relativeTimeInMs} from 'sentry/components/replays/utils'; import SearchBar from 'sentry/components/searchBar'; import {SVGIconProps} from 'sentry/icons/svgIcon'; import {t} from 'sentry/locale'; import space from 'sentry/styles/space'; import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent'; import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers'; import useExtractedCrumbHtml from 'sentry/utils/replays/hooks/useExtractedCrumbHtml'; import type ReplayReader from 'sentry/utils/replays/replayReader'; import useDomFilters from 'sentry/views/replays/detail/domMutations/useDomFilters'; import {getDomMutationsTypes} from 'sentry/views/replays/detail/domMutations/utils'; import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight'; type Props = { replay: ReplayReader; }; // The cache is used to measure the height of each row const cache = new CellMeasurerCache({ fixedWidth: true, minHeight: 82, }); function DomMutations({replay}: Props) { const startTimestampMs = replay.getReplay().startedAt.getTime(); const {currentTime} = useReplayContext(); const {isLoading, actions} = useExtractedCrumbHtml({replay}); let listRef: ReactVirtualizedList | null = null; const { items, type: filteredTypes, searchTerm, setType, setSearchTerm, } = useDomFilters({actions}); const currentDomMutation = getPrevReplayEvent({ items: items.map(mutation => mutation.crumb), targetTimestampMs: startTimestampMs + currentTime, allowEqual: true, allowExact: true, }); const {handleMouseEnter, handleMouseLeave, handleClick} = useCrumbHandlers(startTimestampMs); useEffect(() => { // Restart cache when items changes if (listRef) { cache.clearAll(); listRef?.forceUpdateGrid(); } }, [items, listRef]); const renderRow = ({index, key, style, parent}: ListRowProps) => { const mutation = items[index]; const {html, crumb} = mutation; const {title} = getDetails(crumb); const hasOccurred = currentTime >= relativeTimeInMs(crumb.timestamp || '', startTimestampMs); return ( handleMouseEnter(crumb)} onMouseLeave={() => handleMouseLeave(crumb)} style={style} isCurrent={crumb.id === currentDomMutation?.id} >
{title} {crumb.message}
handleClick(crumb)}>
); }; return ( ({value, label: value}))} size="sm" onChange={selected => setType(selected.map(_ => _.value))} value={filteredTypes} /> {isLoading ? ( ) : ( {({width, height}) => ( { listRef = el; }} deferredMeasurementCache={cache} height={height} overscanRowCount={5} rowCount={items.length} noRowsRenderer={() => actions.length === 0 ? ( {t('No related DOM events recorded')} ) : ( {t('No results found')} ) } rowHeight={cache.rowHeight} rowRenderer={renderRow} width={width} /> )} )} ); } const MutationFilters = styled('div')` display: grid; gap: ${space(1)}; grid-template-columns: max-content 1fr; margin-bottom: ${space(1)}; @media (max-width: ${p => p.theme.breakpoints.small}) { margin-top: ${space(1)}; } `; const MutationContainer = styled(FluidHeight)` height: 100%; `; const MutationList = styled('ul')` list-style: none; position: relative; height: 100%; overflow: hidden; border: 1px solid ${p => p.theme.border}; border-radius: ${p => p.theme.borderRadius}; padding-left: 0; margin-bottom: 0; `; const MutationContent = styled('div')` overflow: hidden; width: 100%; display: flex; flex-direction: column; gap: ${space(1)}; `; const MutationDetailsContainer = styled('div')` display: flex; justify-content: space-between; align-items: flex-start; flex-grow: 1; `; /** * Taken `from events/interfaces/.../breadcrumbs/types` */ const IconWrapper = styled('div')< {hasOccurred?: boolean} & Required> >` display: flex; align-items: center; justify-content: center; width: 24px; min-width: 24px; height: 24px; border-radius: 50%; color: ${p => p.theme.white}; background: ${p => (p.hasOccurred ? p.theme[p.color] ?? p.color : p.theme.purple200)}; box-shadow: ${p => p.theme.dropShadowLightest}; z-index: 2; `; const MutationListItem = styled('li')<{isCurrent?: boolean}>` display: flex; gap: ${space(1)}; flex-grow: 1; padding: ${space(1)} ${space(1.5)}; position: relative; border-bottom: 1px solid ${p => (p.isCurrent ? p.theme.purple300 : 'transparent')}; &:hover { background-color: ${p => p.theme.backgroundSecondary}; } /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */ &::after { content: ''; position: absolute; left: 23.5px; top: 0; width: 1px; background: ${p => p.theme.gray200}; height: 100%; } &:first-of-type::after { top: ${space(1)}; bottom: 0; } &:last-of-type::after { top: 0; height: ${space(1)}; } &:only-of-type::after { height: 0; } `; const TitleContainer = styled('div')` display: flex; justify-content: space-between; `; const Title = styled('span')<{hasOccurred?: boolean}>` ${p => p.theme.overflowEllipsis}; text-transform: capitalize; color: ${p => (p.hasOccurred ? p.theme.gray400 : p.theme.gray300)}; font-weight: bold; line-height: ${p => p.theme.text.lineHeightBody}; `; const UnstyledButton = styled('button')` background: none; border: none; padding: 0; line-height: 0.75; `; const MutationMessage = styled('p')` color: ${p => p.theme.gray300}; font-size: ${p => p.theme.fontSizeSmall}; margin-bottom: 0; `; const CodeContainer = styled('div')` max-height: 400px; max-width: 100%; overflow: auto; `; export default DomMutations;