tableCell.tsx 18 KB

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