tableCell.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import {useTheme} from '@emotion/react';
  2. import styled from '@emotion/styled';
  3. import Avatar from 'sentry/components/avatar';
  4. import UserBadge from 'sentry/components/idBadge/userBadge';
  5. import Link from 'sentry/components/links/link';
  6. import ContextIcon from 'sentry/components/replays/contextIcon';
  7. import {formatTime} from 'sentry/components/replays/utils';
  8. import StringWalker from 'sentry/components/replays/walker/stringWalker';
  9. import ScoreBar from 'sentry/components/scoreBar';
  10. import TimeSince from 'sentry/components/timeSince';
  11. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  12. import {IconCalendar, IconDelete, IconFire} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space, ValidSize} from 'sentry/styles/space';
  15. import type {Organization} from 'sentry/types';
  16. import {trackAnalytics} from 'sentry/utils/analytics';
  17. import EventView from 'sentry/utils/discover/eventView';
  18. import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
  19. import {getShortEventId} from 'sentry/utils/events';
  20. import {useLocation} from 'sentry/utils/useLocation';
  21. import useMedia from 'sentry/utils/useMedia';
  22. import useProjects from 'sentry/utils/useProjects';
  23. import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData';
  24. import type {ReplayListRecord} from 'sentry/views/replays/types';
  25. type Props = {
  26. replay: ReplayListRecord | ReplayListRecordWithTx;
  27. };
  28. function getUserBadgeUser(replay: Props['replay']) {
  29. return replay.is_archived
  30. ? {
  31. username: '',
  32. email: '',
  33. id: '',
  34. ip_address: '',
  35. name: '',
  36. }
  37. : {
  38. username: replay.user?.display_name || '',
  39. email: replay.user?.email || '',
  40. id: replay.user?.id || '',
  41. ip_address: replay.user?.ip || '',
  42. name: replay.user?.username || '',
  43. };
  44. }
  45. export function ReplayCell({
  46. eventView,
  47. organization,
  48. referrer,
  49. replay,
  50. showUrl,
  51. }: Props & {
  52. eventView: EventView;
  53. organization: Organization;
  54. referrer: string;
  55. showUrl: boolean;
  56. }) {
  57. const {projects} = useProjects();
  58. const project = projects.find(p => p.id === replay.project_id);
  59. const replayDetails = {
  60. pathname: `/replays/${replay.id}/`,
  61. query: {
  62. referrer,
  63. ...eventView.generateQueryStringObject(),
  64. },
  65. };
  66. const trackNavigationEvent = () =>
  67. trackAnalytics('replay.list-navigate-to-details', {
  68. project_id: project?.id,
  69. platform: project?.platform,
  70. organization,
  71. referrer,
  72. });
  73. if (replay.is_archived) {
  74. return (
  75. <Item isArchived={replay.is_archived}>
  76. <Row gap={1}>
  77. <StyledIconDelete color="gray500" size="md" />
  78. <div>
  79. <Row gap={0.5}>{t('Deleted Replay')}</Row>
  80. <Row gap={0.5}>
  81. {project ? <Avatar size={12} project={project} /> : null}
  82. {getShortEventId(replay.id)}
  83. </Row>
  84. </div>
  85. </Row>
  86. </Item>
  87. );
  88. }
  89. const subText = (
  90. <Cols>
  91. {showUrl ? <StringWalker urls={replay.urls} /> : undefined}
  92. <Row gap={1}>
  93. <Row gap={0.5}>
  94. {project ? <Avatar size={12} project={project} /> : null}
  95. <Link to={replayDetails} onClick={trackNavigationEvent}>
  96. {getShortEventId(replay.id)}
  97. </Link>
  98. </Row>
  99. <Row gap={0.5}>
  100. <IconCalendar color="gray300" size="xs" />
  101. <TimeSince date={replay.started_at} />
  102. </Row>
  103. </Row>
  104. </Cols>
  105. );
  106. return (
  107. <Item>
  108. <UserBadgeFullWidth
  109. avatarSize={24}
  110. displayName={
  111. replay.is_archived ? (
  112. replay.user.display_name || t('Unknown User')
  113. ) : (
  114. <MainLink to={replayDetails} onClick={trackNavigationEvent}>
  115. {replay.user.display_name || t('Unknown User')}
  116. </MainLink>
  117. )
  118. }
  119. user={getUserBadgeUser(replay)}
  120. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  121. displayEmail={subText}
  122. />
  123. </Item>
  124. );
  125. }
  126. const StyledIconDelete = styled(IconDelete)`
  127. margin: ${space(0.25)};
  128. `;
  129. // Need to be full width for StringWalker to take up full width and truncate properly
  130. const UserBadgeFullWidth = styled(UserBadge)`
  131. width: 100%;
  132. `;
  133. const Cols = styled('div')`
  134. display: flex;
  135. flex-direction: column;
  136. gap: ${space(0.5)};
  137. width: 100%;
  138. `;
  139. const Row = styled('div')<{gap: ValidSize; minWidth?: number}>`
  140. display: flex;
  141. gap: ${p => space(p.gap)};
  142. align-items: center;
  143. ${p => (p.minWidth ? `min-width: ${p.minWidth}px;` : '')}
  144. `;
  145. const MainLink = styled(Link)`
  146. font-size: ${p => p.theme.fontSizeLarge};
  147. `;
  148. export function TransactionCell({
  149. organization,
  150. replay,
  151. }: Props & {organization: Organization}) {
  152. const location = useLocation();
  153. if (replay.is_archived) {
  154. return <Item isArchived />;
  155. }
  156. const hasTxEvent = 'txEvent' in replay;
  157. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  158. return hasTxEvent ? (
  159. <SpanOperationBreakdown>
  160. {txDuration ? <div>{txDuration}ms</div> : null}
  161. {spanOperationRelativeBreakdownRenderer(
  162. replay.txEvent,
  163. {organization, location},
  164. {enableOnClick: false}
  165. )}
  166. </SpanOperationBreakdown>
  167. ) : null;
  168. }
  169. export function OSCell({replay}: Props) {
  170. const {name, version} = replay.os ?? {};
  171. const theme = useTheme();
  172. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  173. if (replay.is_archived) {
  174. return <Item isArchived />;
  175. }
  176. return (
  177. <Item>
  178. <ContextIcon
  179. name={name ?? ''}
  180. version={version && hasRoomForColumns ? version : undefined}
  181. showVersion={false}
  182. />
  183. </Item>
  184. );
  185. }
  186. export function BrowserCell({replay}: Props) {
  187. const {name, version} = replay.browser ?? {};
  188. const theme = useTheme();
  189. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  190. if (replay.is_archived) {
  191. return <Item isArchived />;
  192. }
  193. return (
  194. <Item>
  195. <ContextIcon
  196. name={name ?? ''}
  197. version={version && hasRoomForColumns ? version : undefined}
  198. showVersion={false}
  199. />
  200. </Item>
  201. );
  202. }
  203. export function DurationCell({replay}: Props) {
  204. if (replay.is_archived) {
  205. return <Item isArchived />;
  206. }
  207. return (
  208. <Item>
  209. <Time>{formatTime(replay.duration.asMilliseconds())}</Time>
  210. </Item>
  211. );
  212. }
  213. export function RageClickCountCell({replay}: Props) {
  214. if (replay.is_archived) {
  215. return <Item isArchived />;
  216. }
  217. return (
  218. <Item data-test-id="replay-table-count-rage-clicks">
  219. {replay.count_rage_clicks ? (
  220. <Count>{replay.count_rage_clicks}</Count>
  221. ) : (
  222. <Count>0</Count>
  223. )}
  224. </Item>
  225. );
  226. }
  227. export function DeadClickCountCell({replay}: Props) {
  228. if (replay.is_archived) {
  229. return <Item isArchived />;
  230. }
  231. return (
  232. <Item data-test-id="replay-table-count-dead-clicks">
  233. {replay.count_dead_clicks ? (
  234. <Count>{replay.count_dead_clicks}</Count>
  235. ) : (
  236. <Count>0</Count>
  237. )}
  238. </Item>
  239. );
  240. }
  241. export function ErrorCountCell({replay}: Props) {
  242. if (replay.is_archived) {
  243. return <Item isArchived />;
  244. }
  245. return (
  246. <Item data-test-id="replay-table-count-errors">
  247. {replay.count_errors ? (
  248. <ErrorCount>
  249. <IconFire />
  250. {replay.count_errors}
  251. </ErrorCount>
  252. ) : (
  253. <Count>0</Count>
  254. )}
  255. </Item>
  256. );
  257. }
  258. export function ActivityCell({replay}: Props) {
  259. if (replay.is_archived) {
  260. return <Item isArchived />;
  261. }
  262. const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
  263. return (
  264. <Item>
  265. <ScoreBar
  266. size={20}
  267. score={replay?.activity ?? 1}
  268. palette={scoreBarPalette}
  269. radius={0}
  270. />
  271. </Item>
  272. );
  273. }
  274. const Item = styled('div')<{isArchived?: boolean}>`
  275. display: flex;
  276. align-items: center;
  277. gap: ${space(1)};
  278. padding: ${space(1.5)};
  279. ${p => (p.isArchived ? 'opacity: 0.5;' : '')};
  280. `;
  281. const Count = styled('span')`
  282. font-variant-numeric: tabular-nums;
  283. `;
  284. const ErrorCount = styled(Count)`
  285. display: flex;
  286. align-items: center;
  287. gap: ${space(0.5)};
  288. color: ${p => p.theme.red400};
  289. `;
  290. const Time = styled('span')`
  291. font-variant-numeric: tabular-nums;
  292. `;
  293. const SpanOperationBreakdown = styled('div')`
  294. width: 100%;
  295. display: flex;
  296. flex-direction: column;
  297. gap: ${space(0.5)};
  298. color: ${p => p.theme.gray500};
  299. font-size: ${p => p.theme.fontSizeMedium};
  300. text-align: right;
  301. `;