tableCell.tsx 9.7 KB

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