tableCell.tsx 18 KB

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