tableCell.tsx 8.5 KB

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