tableCell.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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 ErrorCount from 'sentry/components/replays/header/errorCount';
  8. import {formatTime} from 'sentry/components/replays/utils';
  9. import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
  10. import ScoreBar from 'sentry/components/scoreBar';
  11. import TimeSince from 'sentry/components/timeSince';
  12. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  13. import {IconCalendar, IconDelete} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space, ValidSize} from 'sentry/styles/space';
  16. import type {Organization} from 'sentry/types';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import EventView from 'sentry/utils/discover/eventView';
  19. import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
  20. import {getShortEventId} from 'sentry/utils/events';
  21. import {useLocation} from 'sentry/utils/useLocation';
  22. import useMedia from 'sentry/utils/useMedia';
  23. import useProjects from 'sentry/utils/useProjects';
  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. }: Props & {eventView: EventView; organization: Organization; referrer: string}) {
  52. const {projects} = useProjects();
  53. const project = projects.find(p => p.id === replay.project_id);
  54. const replayDetails = {
  55. pathname: `/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/`,
  56. query: {
  57. referrer,
  58. ...eventView.generateQueryStringObject(),
  59. },
  60. };
  61. const trackNavigationEvent = () =>
  62. trackAnalytics('replay.list-navigate-to-details', {
  63. project_id: project?.id,
  64. platform: project?.platform,
  65. organization,
  66. referrer,
  67. });
  68. if (replay.is_archived) {
  69. return (
  70. <Item isArchived={replay.is_archived}>
  71. <Row gap={1}>
  72. <StyledIconDelete color="gray500" size="md" />
  73. <div>
  74. <Row gap={0.5}>{t('Deleted Replay')}</Row>
  75. <Row gap={0.5}>
  76. {project ? <Avatar size={12} project={project} /> : null}
  77. {getShortEventId(replay.id)}
  78. </Row>
  79. </div>
  80. </Row>
  81. </Item>
  82. );
  83. }
  84. const subText = (
  85. <Cols>
  86. <StringWalker urls={replay.urls} />
  87. <Row gap={1}>
  88. <Row gap={0.5}>
  89. {project ? <Avatar size={12} project={project} /> : null}
  90. <Link to={replayDetails} onClick={trackNavigationEvent}>
  91. {getShortEventId(replay.id)}
  92. </Link>
  93. </Row>
  94. <Row gap={0.5}>
  95. <IconCalendar color="gray300" size="xs" />
  96. <TimeSince date={replay.started_at} />
  97. </Row>
  98. </Row>
  99. </Cols>
  100. );
  101. return (
  102. <Item>
  103. <UserBadgeFullWidth
  104. avatarSize={24}
  105. displayName={
  106. replay.is_archived ? (
  107. replay.user.display_name || t('Unknown User')
  108. ) : (
  109. <MainLink to={replayDetails} onClick={trackNavigationEvent}>
  110. {replay.user.display_name || t('Unknown User')}
  111. </MainLink>
  112. )
  113. }
  114. user={getUserBadgeUser(replay)}
  115. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  116. displayEmail={subText}
  117. />
  118. </Item>
  119. );
  120. }
  121. const StyledIconDelete = styled(IconDelete)`
  122. margin: ${space(0.25)};
  123. `;
  124. // Need to be full width for StringWalker to take up full width and truncate properly
  125. const UserBadgeFullWidth = styled(UserBadge)`
  126. width: 100%;
  127. `;
  128. const Cols = styled('div')`
  129. display: flex;
  130. flex-direction: column;
  131. gap: ${space(0.5)};
  132. width: 100%;
  133. `;
  134. const Row = styled('div')<{gap: ValidSize; minWidth?: number}>`
  135. display: flex;
  136. gap: ${p => space(p.gap)};
  137. align-items: center;
  138. ${p => (p.minWidth ? `min-width: ${p.minWidth}px;` : '')}
  139. `;
  140. const MainLink = styled(Link)`
  141. font-size: ${p => p.theme.fontSizeLarge};
  142. `;
  143. export function TransactionCell({
  144. organization,
  145. replay,
  146. }: Props & {organization: Organization}) {
  147. const location = useLocation();
  148. if (replay.is_archived) {
  149. return <Item isArchived />;
  150. }
  151. const hasTxEvent = 'txEvent' in replay;
  152. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  153. return hasTxEvent ? (
  154. <SpanOperationBreakdown>
  155. {txDuration ? <div>{txDuration}ms</div> : null}
  156. {spanOperationRelativeBreakdownRenderer(
  157. replay.txEvent,
  158. {organization, location},
  159. {enableOnClick: false}
  160. )}
  161. </SpanOperationBreakdown>
  162. ) : null;
  163. }
  164. export function OSCell({replay}: Props) {
  165. const {name, version} = replay.os ?? {};
  166. const theme = useTheme();
  167. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  168. if (replay.is_archived) {
  169. return <Item isArchived />;
  170. }
  171. return (
  172. <Item>
  173. <ContextIcon
  174. name={name ?? ''}
  175. version={version && hasRoomForColumns ? version : undefined}
  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. />
  193. </Item>
  194. );
  195. }
  196. export function DurationCell({replay}: Props) {
  197. if (replay.is_archived) {
  198. return <Item isArchived />;
  199. }
  200. return (
  201. <Item>
  202. <Time>{formatTime(replay.duration.asMilliseconds())}</Time>
  203. </Item>
  204. );
  205. }
  206. export function ErrorCountCell({replay}: Props) {
  207. if (replay.is_archived) {
  208. return <Item isArchived />;
  209. }
  210. return (
  211. <Item data-test-id="replay-table-count-errors">
  212. <ErrorCount countErrors={replay.count_errors} />
  213. </Item>
  214. );
  215. }
  216. export function ActivityCell({replay}: Props) {
  217. if (replay.is_archived) {
  218. return <Item isArchived />;
  219. }
  220. const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
  221. return (
  222. <Item>
  223. <ScoreBar
  224. size={20}
  225. score={replay?.activity ?? 1}
  226. palette={scoreBarPalette}
  227. radius={0}
  228. />
  229. </Item>
  230. );
  231. }
  232. const Item = styled('div')<{isArchived?: boolean}>`
  233. display: flex;
  234. align-items: center;
  235. gap: ${space(1)};
  236. padding: ${space(1.5)};
  237. ${p => (p.isArchived ? 'opacity: 0.5;' : '')};
  238. `;
  239. const Time = styled('span')`
  240. font-variant-numeric: tabular-nums;
  241. `;
  242. const SpanOperationBreakdown = styled('div')`
  243. width: 100%;
  244. display: flex;
  245. flex-direction: column;
  246. gap: ${space(0.5)};
  247. color: ${p => p.theme.gray500};
  248. font-size: ${p => p.theme.fontSizeMedium};
  249. text-align: right;
  250. `;