tableCell.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  1. import {browserHistory} from 'react-router';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import type {Location} from 'history';
  5. import Avatar from 'sentry/components/avatar';
  6. import {Button} from 'sentry/components/button';
  7. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  8. import UserBadge from 'sentry/components/idBadge/userBadge';
  9. import Link from 'sentry/components/links/link';
  10. import ContextIcon from 'sentry/components/replays/contextIcon';
  11. import {formatTime} from 'sentry/components/replays/utils';
  12. import StringWalker from 'sentry/components/replays/walker/stringWalker';
  13. import ScoreBar from 'sentry/components/scoreBar';
  14. import TimeSince from 'sentry/components/timeSince';
  15. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  16. import {IconCalendar, IconDelete, IconEllipsis, IconFire} from 'sentry/icons';
  17. import {t, tct} from 'sentry/locale';
  18. import {space, ValidSize} from 'sentry/styles/space';
  19. import type {Organization} from 'sentry/types';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import EventView from 'sentry/utils/discover/eventView';
  22. import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
  23. import {getShortEventId} from 'sentry/utils/events';
  24. import {decodeScalar} from 'sentry/utils/queryString';
  25. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  26. import {useLocation} from 'sentry/utils/useLocation';
  27. import useMedia from 'sentry/utils/useMedia';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  30. import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData';
  31. import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
  32. type Props = {
  33. replay: ReplayListRecord | ReplayListRecordWithTx;
  34. showDropdownFilters?: boolean;
  35. };
  36. export type ReferrerTableType = 'main' | 'dead-table' | 'errors-table' | 'rage-table';
  37. type EditType = 'set' | 'remove';
  38. function generateAction({
  39. key,
  40. value,
  41. edit,
  42. location,
  43. }: {
  44. edit: EditType;
  45. key: string;
  46. location: Location<ReplayListLocationQuery>;
  47. value: string;
  48. }) {
  49. const search = new MutableSearch(decodeScalar(location.query.query) || '');
  50. const modifiedQuery =
  51. edit === 'set' ? search.setFilterValues(key, [value]) : search.removeFilter(key);
  52. const onAction = () => {
  53. browserHistory.push({
  54. pathname: location.pathname,
  55. query: {
  56. ...location.query,
  57. query: modifiedQuery.formatString(),
  58. },
  59. });
  60. };
  61. return onAction;
  62. }
  63. function OSBrowserDropdownFilter({
  64. type,
  65. name,
  66. version,
  67. }: {
  68. name: string | null;
  69. type: string;
  70. version: string | null;
  71. }) {
  72. const location = useLocation<ReplayListLocationQuery>();
  73. return (
  74. <DropdownMenu
  75. items={[
  76. ...(name
  77. ? [
  78. {
  79. key: 'name',
  80. label: tct('[type] name: [name]', {
  81. type: <b>{type}</b>,
  82. name: <b>{name}</b>,
  83. }),
  84. children: [
  85. {
  86. key: 'name_add',
  87. label: t('Add to filter'),
  88. onAction: generateAction({
  89. key: `${type}.name`,
  90. value: name ?? '',
  91. edit: 'set',
  92. location,
  93. }),
  94. },
  95. {
  96. key: 'name_exclude',
  97. label: t('Exclude from filter'),
  98. onAction: generateAction({
  99. key: `${type}.name`,
  100. value: name ?? '',
  101. edit: 'remove',
  102. location,
  103. }),
  104. },
  105. ],
  106. },
  107. ]
  108. : []),
  109. ...(version
  110. ? [
  111. {
  112. key: 'version',
  113. label: tct('[type] version: [version]', {
  114. type: <b>{type}</b>,
  115. version: <b>{version}</b>,
  116. }),
  117. children: [
  118. {
  119. key: 'version_add',
  120. label: t('Add to filter'),
  121. onAction: generateAction({
  122. key: `${type}.version`,
  123. value: version ?? '',
  124. edit: 'set',
  125. location,
  126. }),
  127. },
  128. {
  129. key: 'version_exclude',
  130. label: t('Exclude from filter'),
  131. onAction: generateAction({
  132. key: `${type}.version`,
  133. value: version ?? '',
  134. edit: 'remove',
  135. location,
  136. }),
  137. },
  138. ],
  139. },
  140. ]
  141. : []),
  142. ]}
  143. usePortal
  144. size="xs"
  145. offset={4}
  146. position="bottom"
  147. preventOverflowOptions={{padding: 4}}
  148. flipOptions={{
  149. fallbackPlacements: ['top', 'right-start', 'right-end', 'left-start', 'left-end'],
  150. }}
  151. trigger={triggerProps => (
  152. <ActionMenuTrigger
  153. {...triggerProps}
  154. translucentBorder
  155. aria-label={t('Actions')}
  156. icon={<IconEllipsis size="xs" />}
  157. size="zero"
  158. />
  159. )}
  160. />
  161. );
  162. }
  163. function NumericDropdownFilter({
  164. type,
  165. val,
  166. triggerOverlay,
  167. }: {
  168. type: string;
  169. val: number;
  170. triggerOverlay?: boolean;
  171. }) {
  172. const location = useLocation<ReplayListLocationQuery>();
  173. return (
  174. <DropdownMenu
  175. items={[
  176. {
  177. key: 'add',
  178. label: 'Add to filter',
  179. onAction: generateAction({
  180. key: type,
  181. value: val.toString(),
  182. edit: 'set',
  183. location,
  184. }),
  185. },
  186. {
  187. key: 'greater',
  188. label: 'Show values greater than',
  189. onAction: generateAction({
  190. key: type,
  191. value: '>' + val.toString(),
  192. edit: 'set',
  193. location,
  194. }),
  195. },
  196. {
  197. key: 'less',
  198. label: 'Show values less than',
  199. onAction: generateAction({
  200. key: type,
  201. value: '<' + val.toString(),
  202. edit: 'set',
  203. location,
  204. }),
  205. },
  206. {
  207. key: 'exclude',
  208. label: t('Exclude from filter'),
  209. onAction: generateAction({
  210. key: type,
  211. value: val.toString(),
  212. edit: 'remove',
  213. location,
  214. }),
  215. },
  216. ]}
  217. usePortal
  218. size="xs"
  219. offset={4}
  220. position="bottom"
  221. preventOverflowOptions={{padding: 4}}
  222. flipOptions={{
  223. fallbackPlacements: ['top', 'right-start', 'right-end', 'left-start', 'left-end'],
  224. }}
  225. trigger={triggerProps =>
  226. triggerOverlay ? (
  227. <OverlayActionMenuTrigger
  228. {...triggerProps}
  229. translucentBorder
  230. aria-label={t('Actions')}
  231. icon={<IconEllipsis size="xs" />}
  232. size="zero"
  233. />
  234. ) : (
  235. <NumericActionMenuTrigger
  236. {...triggerProps}
  237. translucentBorder
  238. aria-label={t('Actions')}
  239. icon={<IconEllipsis size="xs" />}
  240. size="zero"
  241. />
  242. )
  243. }
  244. />
  245. );
  246. }
  247. function getUserBadgeUser(replay: Props['replay']) {
  248. return replay.is_archived
  249. ? {
  250. username: '',
  251. email: '',
  252. id: '',
  253. ip_address: '',
  254. name: '',
  255. }
  256. : {
  257. username: replay.user?.display_name || '',
  258. email: replay.user?.email || '',
  259. id: replay.user?.id || '',
  260. ip_address: replay.user?.ip || '',
  261. name: replay.user?.username || '',
  262. };
  263. }
  264. export function ReplayCell({
  265. eventView,
  266. organization,
  267. referrer,
  268. replay,
  269. showUrl,
  270. referrer_table,
  271. }: Props & {
  272. eventView: EventView;
  273. organization: Organization;
  274. referrer: string;
  275. referrer_table: ReferrerTableType;
  276. showUrl: boolean;
  277. }) {
  278. const {projects} = useProjects();
  279. const project = projects.find(p => p.id === replay.project_id);
  280. const replayDetails = {
  281. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`),
  282. query: {
  283. referrer,
  284. ...eventView.generateQueryStringObject(),
  285. },
  286. };
  287. const replayDetailsErrorTab = {
  288. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`),
  289. query: {
  290. referrer,
  291. ...eventView.generateQueryStringObject(),
  292. t_main: 'errors',
  293. },
  294. };
  295. const replayDetailsDOMEventsTab = {
  296. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`),
  297. query: {
  298. referrer,
  299. ...eventView.generateQueryStringObject(),
  300. t_main: 'dom',
  301. f_d_type: 'ui.slowClickDetected',
  302. },
  303. };
  304. const detailsTab = () => {
  305. switch (referrer_table) {
  306. case 'errors-table':
  307. return replayDetailsErrorTab;
  308. case 'dead-table':
  309. return replayDetailsDOMEventsTab;
  310. case 'rage-table':
  311. return replayDetailsDOMEventsTab;
  312. default:
  313. return replayDetails;
  314. }
  315. };
  316. const trackNavigationEvent = () =>
  317. trackAnalytics('replay.list-navigate-to-details', {
  318. project_id: project?.id,
  319. platform: project?.platform,
  320. organization,
  321. referrer,
  322. referrer_table,
  323. });
  324. if (replay.is_archived) {
  325. return (
  326. <Item isArchived={replay.is_archived}>
  327. <Row gap={1}>
  328. <StyledIconDelete color="gray500" size="md" />
  329. <div>
  330. <Row gap={0.5}>{t('Deleted Replay')}</Row>
  331. <Row gap={0.5}>
  332. {project ? <Avatar size={12} project={project} /> : null}
  333. {getShortEventId(replay.id)}
  334. </Row>
  335. </div>
  336. </Row>
  337. </Item>
  338. );
  339. }
  340. const subText = (
  341. <Cols>
  342. {showUrl ? <StringWalker urls={replay.urls} /> : undefined}
  343. <Row gap={1}>
  344. <Row gap={0.5}>
  345. {/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */}
  346. {project ? <Avatar size={12} project={project} /> : null}
  347. {project ? project.slug : null}
  348. <Link to={detailsTab} onClick={trackNavigationEvent}>
  349. {getShortEventId(replay.id)}
  350. </Link>
  351. </Row>
  352. <Row gap={0.5}>
  353. <IconCalendar color="gray300" size="xs" />
  354. <TimeSince date={replay.started_at} />
  355. </Row>
  356. </Row>
  357. </Cols>
  358. );
  359. return (
  360. <Item>
  361. <UserBadgeFullWidth
  362. avatarSize={24}
  363. displayName={
  364. replay.is_archived ? (
  365. replay.user.display_name || t('Unknown User')
  366. ) : (
  367. <MainLink to={detailsTab} onClick={trackNavigationEvent}>
  368. {replay.user.display_name || t('Unknown User')}
  369. </MainLink>
  370. )
  371. }
  372. user={getUserBadgeUser(replay)}
  373. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  374. displayEmail={subText}
  375. />
  376. </Item>
  377. );
  378. }
  379. const StyledIconDelete = styled(IconDelete)`
  380. margin: ${space(0.25)};
  381. `;
  382. // Need to be full width for StringWalker to take up full width and truncate properly
  383. const UserBadgeFullWidth = styled(UserBadge)`
  384. width: 100%;
  385. `;
  386. const Cols = styled('div')`
  387. display: flex;
  388. flex-direction: column;
  389. gap: ${space(0.5)};
  390. width: 100%;
  391. `;
  392. const Row = styled('div')<{gap: ValidSize; minWidth?: number}>`
  393. display: flex;
  394. gap: ${p => space(p.gap)};
  395. align-items: center;
  396. ${p => (p.minWidth ? `min-width: ${p.minWidth}px;` : '')}
  397. `;
  398. const MainLink = styled(Link)`
  399. font-size: ${p => p.theme.fontSizeLarge};
  400. `;
  401. export function TransactionCell({
  402. organization,
  403. replay,
  404. }: Props & {organization: Organization}) {
  405. const location = useLocation();
  406. if (replay.is_archived) {
  407. return <Item isArchived />;
  408. }
  409. const hasTxEvent = 'txEvent' in replay;
  410. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  411. return hasTxEvent ? (
  412. <Item>
  413. <SpanOperationBreakdown>
  414. {txDuration ? <div>{txDuration}ms</div> : null}
  415. {spanOperationRelativeBreakdownRenderer(
  416. replay.txEvent,
  417. {organization, location},
  418. {enableOnClick: false}
  419. )}
  420. </SpanOperationBreakdown>
  421. </Item>
  422. ) : null;
  423. }
  424. export function OSCell({replay, showDropdownFilters}: Props) {
  425. const {name, version} = replay.os ?? {};
  426. const theme = useTheme();
  427. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  428. if (replay.is_archived) {
  429. return <Item isArchived />;
  430. }
  431. return (
  432. <Item>
  433. <Container>
  434. <ContextIcon
  435. name={name ?? ''}
  436. version={version && hasRoomForColumns ? version : undefined}
  437. showVersion={false}
  438. showTooltip={false}
  439. />
  440. {showDropdownFilters ? (
  441. <OSBrowserDropdownFilter type="os" name={name} version={version} />
  442. ) : null}
  443. </Container>
  444. </Item>
  445. );
  446. }
  447. export function BrowserCell({replay, showDropdownFilters}: Props) {
  448. const {name, version} = replay.browser ?? {};
  449. const theme = useTheme();
  450. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  451. if (replay.is_archived) {
  452. return <Item isArchived />;
  453. }
  454. return (
  455. <Item>
  456. <Container>
  457. <ContextIcon
  458. name={name ?? ''}
  459. version={version && hasRoomForColumns ? version : undefined}
  460. showVersion={false}
  461. showTooltip={false}
  462. />
  463. {showDropdownFilters ? (
  464. <OSBrowserDropdownFilter type="browser" name={name} version={version} />
  465. ) : null}
  466. </Container>
  467. </Item>
  468. );
  469. }
  470. export function DurationCell({replay, showDropdownFilters}: Props) {
  471. if (replay.is_archived) {
  472. return <Item isArchived />;
  473. }
  474. return (
  475. <Item>
  476. <Container>
  477. <Time>{formatTime(replay.duration.asMilliseconds())}</Time>
  478. {showDropdownFilters ? (
  479. <NumericDropdownFilter type="duration" val={replay.duration.asSeconds()} />
  480. ) : null}
  481. </Container>
  482. </Item>
  483. );
  484. }
  485. export function RageClickCountCell({replay, showDropdownFilters}: Props) {
  486. if (replay.is_archived) {
  487. return <Item isArchived />;
  488. }
  489. return (
  490. <Item data-test-id="replay-table-count-rage-clicks">
  491. <Container>
  492. {replay.count_rage_clicks ? (
  493. <DeadRageCount>{replay.count_rage_clicks}</DeadRageCount>
  494. ) : (
  495. <Count>0</Count>
  496. )}
  497. {showDropdownFilters ? (
  498. <NumericDropdownFilter
  499. type="count_rage_clicks"
  500. val={replay.count_rage_clicks ?? 0}
  501. />
  502. ) : null}
  503. </Container>
  504. </Item>
  505. );
  506. }
  507. export function DeadClickCountCell({replay, showDropdownFilters}: Props) {
  508. if (replay.is_archived) {
  509. return <Item isArchived />;
  510. }
  511. return (
  512. <Item data-test-id="replay-table-count-dead-clicks">
  513. <Container>
  514. {replay.count_dead_clicks ? (
  515. <DeadRageCount>{replay.count_dead_clicks}</DeadRageCount>
  516. ) : (
  517. <Count>0</Count>
  518. )}
  519. {showDropdownFilters ? (
  520. <NumericDropdownFilter
  521. type="count_dead_clicks"
  522. val={replay.count_dead_clicks ?? 0}
  523. />
  524. ) : null}
  525. </Container>
  526. </Item>
  527. );
  528. }
  529. export function ErrorCountCell({replay, showDropdownFilters}: Props) {
  530. if (replay.is_archived) {
  531. return <Item isArchived />;
  532. }
  533. return (
  534. <Item data-test-id="replay-table-count-errors">
  535. <Container>
  536. {replay.count_errors ? (
  537. <ErrorCount>
  538. <IconFire />
  539. {replay.count_errors}
  540. </ErrorCount>
  541. ) : (
  542. <Count>0</Count>
  543. )}
  544. {showDropdownFilters ? (
  545. <NumericDropdownFilter type="count_errors" val={replay.count_errors ?? 0} />
  546. ) : null}
  547. </Container>
  548. </Item>
  549. );
  550. }
  551. export function ActivityCell({replay, showDropdownFilters}: Props) {
  552. if (replay.is_archived) {
  553. return <Item isArchived />;
  554. }
  555. const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
  556. return (
  557. <Item>
  558. <Container>
  559. <ScoreBar
  560. size={20}
  561. score={replay?.activity ?? 1}
  562. palette={scoreBarPalette}
  563. radius={0}
  564. />
  565. {showDropdownFilters ? (
  566. <NumericDropdownFilter
  567. type="activity"
  568. val={replay?.activity ?? 0}
  569. triggerOverlay
  570. />
  571. ) : null}
  572. </Container>
  573. </Item>
  574. );
  575. }
  576. const Item = styled('div')<{isArchived?: boolean}>`
  577. display: flex;
  578. align-items: center;
  579. gap: ${space(1)};
  580. padding: ${space(1.5)};
  581. ${p => (p.isArchived ? 'opacity: 0.5;' : '')};
  582. `;
  583. const Count = styled('span')`
  584. font-variant-numeric: tabular-nums;
  585. `;
  586. const DeadRageCount = styled(Count)`
  587. display: flex;
  588. width: 40px;
  589. `;
  590. const ErrorCount = styled(Count)`
  591. display: flex;
  592. align-items: center;
  593. gap: ${space(0.5)};
  594. color: ${p => p.theme.red400};
  595. `;
  596. const Time = styled('span')`
  597. font-variant-numeric: tabular-nums;
  598. `;
  599. const SpanOperationBreakdown = styled('div')`
  600. width: 100%;
  601. display: flex;
  602. flex-direction: column;
  603. gap: ${space(0.5)};
  604. color: ${p => p.theme.gray500};
  605. font-size: ${p => p.theme.fontSizeMedium};
  606. text-align: right;
  607. `;
  608. const Container = styled('div')`
  609. position: relative;
  610. display: flex;
  611. flex-direction: column;
  612. justify-content: center;
  613. `;
  614. const ActionMenuTrigger = styled(Button)`
  615. position: absolute;
  616. top: 50%;
  617. transform: translateY(-50%);
  618. padding: ${space(0.75)};
  619. left: -${space(0.75)};
  620. display: flex;
  621. align-items: center;
  622. opacity: 0;
  623. transition: opacity 0.1s;
  624. &.focus-visible,
  625. &[aria-expanded='true'],
  626. ${Container}:hover & {
  627. opacity: 1;
  628. }
  629. `;
  630. const NumericActionMenuTrigger = styled(ActionMenuTrigger)`
  631. left: 100%;
  632. margin-left: ${space(0.75)};
  633. z-index: 1;
  634. `;
  635. const OverlayActionMenuTrigger = styled(NumericActionMenuTrigger)`
  636. right: 0%;
  637. left: unset;
  638. `;