breadcrumbItem.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import {CSSProperties, isValidElement, memo, MouseEvent, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import beautify from 'js-beautify';
  4. import {CodeSnippet} from 'sentry/components/codeSnippet';
  5. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  6. import ObjectInspector from 'sentry/components/objectInspector';
  7. import PanelItem from 'sentry/components/panels/panelItem';
  8. import {Tooltip} from 'sentry/components/tooltip';
  9. import {space} from 'sentry/styles/space';
  10. import {Extraction} from 'sentry/utils/replays/extractDomNodes';
  11. import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
  12. import type {ReplayFrame} from 'sentry/utils/replays/types';
  13. import {isErrorFrame} from 'sentry/utils/replays/types';
  14. import useProjects from 'sentry/utils/useProjects';
  15. import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
  16. import TraceGrid from 'sentry/views/replays/detail/perfTable/traceGrid';
  17. import {ReplayTraceRow} from 'sentry/views/replays/detail/perfTable/useReplayPerfData';
  18. import TimestampButton from 'sentry/views/replays/detail/timestampButton';
  19. type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent<HTMLElement>) => void;
  20. interface Props {
  21. extraction: Extraction | undefined;
  22. frame: ReplayFrame;
  23. onClick: null | MouseCallback;
  24. onDimensionChange: () => void;
  25. onInspectorExpanded: (
  26. path: string,
  27. expandedState: Record<string, boolean>,
  28. event: MouseEvent<HTMLDivElement>
  29. ) => void;
  30. onMouseEnter: MouseCallback;
  31. onMouseLeave: MouseCallback;
  32. startTimestampMs: number;
  33. traces: ReplayTraceRow | undefined;
  34. className?: string;
  35. expandPaths?: string[];
  36. style?: CSSProperties;
  37. }
  38. function getCrumbOrFrameData(frame: ReplayFrame) {
  39. return {
  40. ...getFrameDetails(frame),
  41. projectSlug: isErrorFrame(frame) ? frame.data.projectSlug : null,
  42. timestampMs: frame.timestampMs,
  43. };
  44. }
  45. function BreadcrumbItem({
  46. className,
  47. extraction,
  48. frame,
  49. expandPaths,
  50. onClick,
  51. onDimensionChange,
  52. onInspectorExpanded,
  53. onMouseEnter,
  54. onMouseLeave,
  55. startTimestampMs,
  56. style,
  57. traces,
  58. }: Props) {
  59. const {color, description, projectSlug, title, icon, timestampMs} =
  60. getCrumbOrFrameData(frame);
  61. return (
  62. <CrumbItem
  63. as={onClick ? 'button' : 'span'}
  64. onClick={e => onClick?.(frame, e)}
  65. onMouseEnter={e => onMouseEnter(frame, e)}
  66. onMouseLeave={e => onMouseLeave(frame, e)}
  67. style={style}
  68. className={className}
  69. >
  70. <IconWrapper color={color} hasOccurred>
  71. {icon}
  72. </IconWrapper>
  73. <CrumbDetails>
  74. <TitleContainer>
  75. <Title>{title}</Title>
  76. {onClick ? (
  77. <TimestampButton
  78. startTimestampMs={startTimestampMs}
  79. timestampMs={timestampMs}
  80. />
  81. ) : null}
  82. </TitleContainer>
  83. {typeof description === 'string' || isValidElement(description) ? (
  84. <Description title={description} showOnlyOnOverflow>
  85. {description}
  86. </Description>
  87. ) : (
  88. <InspectorWrapper>
  89. <ObjectInspector
  90. data={description}
  91. expandPaths={expandPaths}
  92. onExpand={onInspectorExpanded}
  93. theme={{
  94. TREENODE_FONT_SIZE: '0.7rem',
  95. ARROW_FONT_SIZE: '0.5rem',
  96. }}
  97. />
  98. </InspectorWrapper>
  99. )}
  100. {extraction?.html ? (
  101. <CodeContainer>
  102. <CodeSnippet language="html" hideCopyButton>
  103. {beautify.html(extraction?.html, {indent_size: 2})}
  104. </CodeSnippet>
  105. </CodeContainer>
  106. ) : null}
  107. {traces?.flattenedTraces.map((flatTrace, i) => (
  108. <TraceGrid
  109. key={i}
  110. flattenedTrace={flatTrace}
  111. onDimensionChange={onDimensionChange}
  112. />
  113. ))}
  114. {projectSlug ? <CrumbProject projectSlug={projectSlug} /> : null}
  115. </CrumbDetails>
  116. </CrumbItem>
  117. );
  118. }
  119. function CrumbProject({projectSlug}: {projectSlug: string}) {
  120. const {projects} = useProjects();
  121. const project = useMemo(
  122. () => projects.find(p => p.slug === projectSlug),
  123. [projects, projectSlug]
  124. );
  125. if (!project) {
  126. return <CrumbProjectBadgeWrapper>{projectSlug}</CrumbProjectBadgeWrapper>;
  127. }
  128. return (
  129. <CrumbProjectBadgeWrapper>
  130. <ProjectBadge project={project} avatarSize={16} disableLink />
  131. </CrumbProjectBadgeWrapper>
  132. );
  133. }
  134. const CrumbProjectBadgeWrapper = styled('div')`
  135. font-size: ${p => p.theme.fontSizeSmall};
  136. color: ${p => p.theme.subText};
  137. margin-top: ${space(0.25)};
  138. `;
  139. const InspectorWrapper = styled('div')`
  140. font-family: ${p => p.theme.text.familyMono};
  141. `;
  142. const CrumbDetails = styled('div')`
  143. display: flex;
  144. flex-direction: column;
  145. overflow: hidden;
  146. `;
  147. const TitleContainer = styled('div')`
  148. display: flex;
  149. justify-content: space-between;
  150. gap: ${space(1)};
  151. font-size: ${p => p.theme.fontSizeSmall};
  152. `;
  153. const Title = styled('span')`
  154. ${p => p.theme.overflowEllipsis};
  155. text-transform: capitalize;
  156. font-size: ${p => p.theme.fontSizeMedium};
  157. font-weight: 600;
  158. color: ${p => p.theme.gray400};
  159. line-height: ${p => p.theme.text.lineHeightBody};
  160. `;
  161. const Description = styled(Tooltip)`
  162. ${p => p.theme.overflowEllipsis};
  163. font-size: 0.7rem;
  164. font-variant-numeric: tabular-nums;
  165. line-height: ${p => p.theme.text.lineHeightBody};
  166. color: ${p => p.theme.subText};
  167. `;
  168. const CrumbItem = styled(PanelItem)`
  169. display: grid;
  170. grid-template-columns: max-content auto;
  171. align-items: flex-start;
  172. gap: ${space(1)};
  173. width: 100%;
  174. font-size: ${p => p.theme.fontSizeMedium};
  175. background: transparent;
  176. padding: ${space(1)};
  177. text-align: left;
  178. border: none;
  179. position: relative;
  180. border-radius: ${p => p.theme.borderRadius};
  181. &:hover {
  182. background-color: ${p => p.theme.surface200};
  183. }
  184. /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */
  185. &::after {
  186. content: '';
  187. position: absolute;
  188. left: 19.5px;
  189. width: 1px;
  190. background: ${p => p.theme.gray200};
  191. height: 100%;
  192. }
  193. &:first-of-type::after {
  194. top: ${space(1)};
  195. bottom: 0;
  196. }
  197. &:last-of-type::after {
  198. top: 0;
  199. height: ${space(1)};
  200. }
  201. &:only-of-type::after {
  202. height: 0;
  203. }
  204. `;
  205. const CodeContainer = styled('div')`
  206. margin-top: ${space(1)};
  207. max-height: 400px;
  208. max-width: 100%;
  209. overflow: auto;
  210. `;
  211. export default memo(BreadcrumbItem);