tableCell.tsx 8.3 KB

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