breadcrumbItem.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import type {CSSProperties, MouseEvent} from 'react';
  2. import {isValidElement, memo} from 'react';
  3. import styled from '@emotion/styled';
  4. import beautify from 'js-beautify';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import {CodeSnippet} from 'sentry/components/codeSnippet';
  7. import ErrorBoundary from 'sentry/components/errorBoundary';
  8. import Link from 'sentry/components/links/link';
  9. import ObjectInspector from 'sentry/components/objectInspector';
  10. import PanelItem from 'sentry/components/panels/panelItem';
  11. import {Flex} from 'sentry/components/profiling/flex';
  12. import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
  13. import {useReplayContext} from 'sentry/components/replays/replayContext';
  14. import {useReplayGroupContext} from 'sentry/components/replays/replayGroupContext';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import {space} from 'sentry/styles/space';
  17. import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
  18. import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
  19. import type {ErrorFrame, FeedbackFrame, ReplayFrame} from 'sentry/utils/replays/types';
  20. import {isErrorFrame, isFeedbackFrame} from 'sentry/utils/replays/types';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
  23. import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
  24. import TimestampButton from 'sentry/views/replays/detail/timestampButton';
  25. type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent<HTMLElement>) => void;
  26. const FRAMES_WITH_BUTTONS = ['replay.hydrate-error'];
  27. interface Props {
  28. extraction: Extraction | undefined;
  29. frame: ReplayFrame;
  30. onClick: null | MouseCallback;
  31. onInspectorExpanded: (
  32. path: string,
  33. expandedState: Record<string, boolean>,
  34. event: MouseEvent<HTMLDivElement>
  35. ) => void;
  36. onMouseEnter: MouseCallback;
  37. onMouseLeave: MouseCallback;
  38. startTimestampMs: number;
  39. className?: string;
  40. expandPaths?: string[];
  41. style?: CSSProperties;
  42. }
  43. function BreadcrumbItem({
  44. className,
  45. extraction,
  46. frame,
  47. expandPaths,
  48. onClick,
  49. onInspectorExpanded,
  50. onMouseEnter,
  51. onMouseLeave,
  52. startTimestampMs,
  53. style,
  54. }: Props) {
  55. const {color, description, title, icon} = getFrameDetails(frame);
  56. const {replay} = useReplayContext();
  57. const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category);
  58. return (
  59. <CrumbItem
  60. isErrorFrame={isErrorFrame(frame)}
  61. as={onClick && !forceSpan ? 'button' : 'span'}
  62. onClick={e => onClick?.(frame, e)}
  63. onMouseEnter={e => onMouseEnter(frame, e)}
  64. onMouseLeave={e => onMouseLeave(frame, e)}
  65. style={style}
  66. className={className}
  67. >
  68. <IconWrapper color={color} hasOccurred>
  69. {icon}
  70. </IconWrapper>
  71. <ErrorBoundary mini>
  72. <CrumbDetails>
  73. <Flex column>
  74. <TitleContainer>
  75. {<Title>{title}</Title>}
  76. {onClick ? (
  77. <TimestampButton
  78. startTimestampMs={startTimestampMs}
  79. timestampMs={frame.timestampMs}
  80. />
  81. ) : null}
  82. </TitleContainer>
  83. {typeof description === 'string' ||
  84. (description !== undefined && isValidElement(description)) ? (
  85. <Description title={description} showOnlyOnOverflow isHoverable>
  86. {description}
  87. </Description>
  88. ) : (
  89. <InspectorWrapper>
  90. <ObjectInspector
  91. data={description}
  92. expandPaths={expandPaths}
  93. onExpand={onInspectorExpanded}
  94. theme={{
  95. TREENODE_FONT_SIZE: '0.7rem',
  96. ARROW_FONT_SIZE: '0.5rem',
  97. }}
  98. />
  99. </InspectorWrapper>
  100. )}
  101. </Flex>
  102. {'data' in frame && frame.data && 'mutations' in frame.data ? (
  103. <div>
  104. <OpenReplayComparisonButton
  105. replay={replay}
  106. leftTimestamp={frame.offsetMs}
  107. rightTimestamp={
  108. (frame.data.mutations.next?.timestamp ?? 0) -
  109. (replay?.getReplay().started_at.getTime() ?? 0)
  110. }
  111. />
  112. </div>
  113. ) : null}
  114. {extraction?.html ? (
  115. <CodeContainer>
  116. <CodeSnippet language="html" hideCopyButton>
  117. {beautify.html(extraction?.html, {indent_size: 2})}
  118. </CodeSnippet>
  119. </CodeContainer>
  120. ) : null}
  121. {isErrorFrame(frame) || isFeedbackFrame(frame) ? (
  122. <CrumbErrorIssue frame={frame} />
  123. ) : null}
  124. </CrumbDetails>
  125. </ErrorBoundary>
  126. </CrumbItem>
  127. );
  128. }
  129. function CrumbErrorIssue({frame}: {frame: FeedbackFrame | ErrorFrame}) {
  130. const organization = useOrganization();
  131. const project = useProjectFromSlug({organization, projectSlug: frame.data.projectSlug});
  132. const {groupId} = useReplayGroupContext();
  133. const projectAvatar = project ? <ProjectAvatar project={project} size={16} /> : null;
  134. if (String(frame.data.groupId) === groupId) {
  135. return (
  136. <CrumbIssueWrapper>
  137. {projectAvatar}
  138. {frame.data.groupShortId}
  139. </CrumbIssueWrapper>
  140. );
  141. }
  142. return (
  143. <CrumbIssueWrapper>
  144. {projectAvatar}
  145. <Link
  146. to={
  147. isFeedbackFrame(frame)
  148. ? {
  149. pathname: `/organizations/${organization.slug}/feedback/`,
  150. query: {feedbackSlug: `${frame.data.projectSlug}:${frame.data.groupId}`},
  151. }
  152. : `/organizations/${organization.slug}/issues/${frame.data.groupId}/`
  153. }
  154. >
  155. {frame.data.groupShortId}
  156. </Link>
  157. </CrumbIssueWrapper>
  158. );
  159. }
  160. const CrumbIssueWrapper = styled('div')`
  161. display: flex;
  162. align-items: center;
  163. gap: ${space(0.5)};
  164. font-size: ${p => p.theme.fontSizeSmall};
  165. color: ${p => p.theme.subText};
  166. `;
  167. const InspectorWrapper = styled('div')`
  168. font-family: ${p => p.theme.text.familyMono};
  169. `;
  170. const CrumbDetails = styled('div')`
  171. display: flex;
  172. flex-direction: column;
  173. overflow: hidden;
  174. gap: ${space(0.5)};
  175. `;
  176. const TitleContainer = styled('div')`
  177. display: flex;
  178. justify-content: space-between;
  179. gap: ${space(1)};
  180. font-size: ${p => p.theme.fontSizeSmall};
  181. `;
  182. const Title = styled('span')`
  183. ${p => p.theme.overflowEllipsis};
  184. text-transform: capitalize;
  185. font-size: ${p => p.theme.fontSizeMedium};
  186. font-weight: 600;
  187. color: ${p => p.theme.gray400};
  188. line-height: ${p => p.theme.text.lineHeightBody};
  189. `;
  190. const Description = styled(Tooltip)`
  191. ${p => p.theme.overflowEllipsis};
  192. font-size: 0.7rem;
  193. font-variant-numeric: tabular-nums;
  194. line-height: ${p => p.theme.text.lineHeightBody};
  195. color: ${p => p.theme.subText};
  196. `;
  197. const CrumbItem = styled(PanelItem)<{isErrorFrame?: boolean}>`
  198. display: grid;
  199. grid-template-columns: max-content auto;
  200. align-items: flex-start;
  201. gap: ${space(1)};
  202. width: 100%;
  203. font-size: ${p => p.theme.fontSizeMedium};
  204. background: ${p => (p.isErrorFrame ? `${p.theme.red100}` : `transparent`)};
  205. padding: ${space(1)};
  206. text-align: left;
  207. border: none;
  208. position: relative;
  209. border-radius: ${p => p.theme.borderRadius};
  210. &:hover {
  211. background-color: ${p => p.theme.surface200};
  212. }
  213. /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */
  214. &::after {
  215. content: '';
  216. position: absolute;
  217. left: 19.5px;
  218. width: 1px;
  219. background: ${p => p.theme.gray200};
  220. height: 100%;
  221. }
  222. &:first-of-type::after {
  223. top: ${space(1)};
  224. bottom: 0;
  225. }
  226. &:last-of-type::after {
  227. top: 0;
  228. height: ${space(1)};
  229. }
  230. &:only-of-type::after {
  231. height: 0;
  232. }
  233. `;
  234. const CodeContainer = styled('div')`
  235. max-height: 400px;
  236. max-width: 100%;
  237. overflow: auto;
  238. `;
  239. export default memo(BreadcrumbItem);