tableCell.tsx 18 KB

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