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 ScoreBar from 'sentry/components/scoreBar';
  13. import TimeSince from 'sentry/components/timeSince';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  16. import {
  17. IconCalendar,
  18. IconCursorArrow,
  19. IconDelete,
  20. IconEllipsis,
  21. IconFire,
  22. } from 'sentry/icons';
  23. import {t, tct} from 'sentry/locale';
  24. import type {ValidSize} from 'sentry/styles/space';
  25. import {space} from 'sentry/styles/space';
  26. import type {Organization} from 'sentry/types';
  27. import {trackAnalytics} from 'sentry/utils/analytics';
  28. import type 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' | 'selector-widget';
  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. referrer_table,
  277. isWidget,
  278. className,
  279. }: Props & {
  280. eventView: EventView;
  281. organization: Organization;
  282. referrer: string;
  283. className?: string;
  284. isWidget?: boolean;
  285. referrer_table?: ReferrerTableType;
  286. }) {
  287. const {projects} = useProjects();
  288. const project = projects.find(p => p.id === replay.project_id);
  289. const replayDetails = {
  290. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`),
  291. query: {
  292. referrer,
  293. ...eventView.generateQueryStringObject(),
  294. },
  295. };
  296. const replayDetailsDeadRage = {
  297. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${replay.id}/`),
  298. query: {
  299. referrer,
  300. ...eventView.generateQueryStringObject(),
  301. f_b_type: 'rageOrDead',
  302. },
  303. };
  304. const detailsTab = () => {
  305. switch (referrer_table) {
  306. case 'selector-widget':
  307. return replayDetailsDeadRage;
  308. default:
  309. return replayDetails;
  310. }
  311. };
  312. const trackNavigationEvent = () =>
  313. trackAnalytics('replay.list-navigate-to-details', {
  314. project_id: project?.id,
  315. platform: project?.platform,
  316. organization,
  317. referrer,
  318. referrer_table,
  319. });
  320. if (replay.is_archived) {
  321. return (
  322. <Item isArchived={replay.is_archived} isReplayCell>
  323. <Row gap={1}>
  324. <StyledIconDelete color="gray500" size="md" />
  325. <div>
  326. <Row gap={0.5}>{t('Deleted Replay')}</Row>
  327. <Row gap={0.5}>
  328. {project ? <Avatar size={12} project={project} /> : null}
  329. <ArchivedId>{getShortEventId(replay.id)}</ArchivedId>
  330. </Row>
  331. </div>
  332. </Row>
  333. </Item>
  334. );
  335. }
  336. const subText = (
  337. <Cols>
  338. <Row gap={1}>
  339. <Row gap={0.5}>
  340. {/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */}
  341. {project ? <Avatar size={12} project={project} /> : null}
  342. {project ? project.slug : null}
  343. <Link to={detailsTab} onClick={trackNavigationEvent}>
  344. {getShortEventId(replay.id)}
  345. </Link>
  346. <Row gap={0.5}>
  347. <IconCalendar color="gray300" size="xs" />
  348. <TimeSince date={replay.started_at} />
  349. </Row>
  350. </Row>
  351. </Row>
  352. </Cols>
  353. );
  354. return (
  355. <Item isWidget={isWidget} isReplayCell className={className}>
  356. <UserBadge
  357. avatarSize={24}
  358. displayName={
  359. replay.is_archived ? (
  360. replay.user.display_name || t('Anonymous User')
  361. ) : (
  362. <MainLink to={detailsTab} onClick={trackNavigationEvent}>
  363. {replay.user.display_name || t('Anonymous User')}
  364. </MainLink>
  365. )
  366. }
  367. user={getUserBadgeUser(replay)}
  368. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  369. displayEmail={subText}
  370. />
  371. </Item>
  372. );
  373. }
  374. const ArchivedId = styled('div')`
  375. font-size: ${p => p.theme.fontSizeSmall};
  376. `;
  377. const StyledIconDelete = styled(IconDelete)`
  378. margin: ${space(0.25)};
  379. `;
  380. const Cols = styled('div')`
  381. display: flex;
  382. flex-direction: column;
  383. gap: ${space(0.5)};
  384. width: 100%;
  385. `;
  386. const Row = styled('div')<{gap: ValidSize; minWidth?: number}>`
  387. display: flex;
  388. gap: ${p => space(p.gap)};
  389. align-items: center;
  390. ${p => (p.minWidth ? `min-width: ${p.minWidth}px;` : '')}
  391. `;
  392. const MainLink = styled(Link)`
  393. font-size: ${p => p.theme.fontSizeLarge};
  394. `;
  395. export function TransactionCell({
  396. organization,
  397. replay,
  398. }: Props & {organization: Organization}) {
  399. const location = useLocation();
  400. if (replay.is_archived) {
  401. return <Item isArchived />;
  402. }
  403. const hasTxEvent = 'txEvent' in replay;
  404. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  405. return hasTxEvent ? (
  406. <Item>
  407. <SpanOperationBreakdown>
  408. {txDuration ? <div>{txDuration}ms</div> : null}
  409. {spanOperationRelativeBreakdownRenderer(
  410. replay.txEvent,
  411. {organization, location},
  412. {enableOnClick: false}
  413. )}
  414. </SpanOperationBreakdown>
  415. </Item>
  416. ) : null;
  417. }
  418. export function OSCell({replay, showDropdownFilters}: Props) {
  419. const {name, version} = replay.os ?? {};
  420. const theme = useTheme();
  421. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  422. if (replay.is_archived) {
  423. return <Item isArchived />;
  424. }
  425. return (
  426. <Item>
  427. <Container>
  428. <Tooltip title={`${name ?? ''} ${version ?? ''}`}>
  429. <ContextIcon
  430. name={name ?? ''}
  431. version={version && hasRoomForColumns ? version : undefined}
  432. showVersion={false}
  433. showTooltip={false}
  434. />
  435. {showDropdownFilters ? (
  436. <OSBrowserDropdownFilter type="os" name={name} version={version} />
  437. ) : null}
  438. </Tooltip>
  439. </Container>
  440. </Item>
  441. );
  442. }
  443. export function BrowserCell({replay, showDropdownFilters}: Props) {
  444. const {name, version} = replay.browser ?? {};
  445. const theme = useTheme();
  446. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  447. if (replay.is_archived) {
  448. return <Item isArchived />;
  449. }
  450. return (
  451. <Item>
  452. <Container>
  453. <Tooltip title={`${name} ${version}`}>
  454. <ContextIcon
  455. name={name ?? ''}
  456. version={version && hasRoomForColumns ? version : undefined}
  457. showVersion={false}
  458. showTooltip={false}
  459. />
  460. {showDropdownFilters ? (
  461. <OSBrowserDropdownFilter type="browser" name={name} version={version} />
  462. ) : null}
  463. </Tooltip>
  464. </Container>
  465. </Item>
  466. );
  467. }
  468. export function DurationCell({replay, showDropdownFilters}: Props) {
  469. if (replay.is_archived) {
  470. return <Item isArchived />;
  471. }
  472. return (
  473. <Item>
  474. <Container>
  475. <Time>{formatTime(replay.duration.asMilliseconds())}</Time>
  476. {showDropdownFilters ? (
  477. <NumericDropdownFilter type="duration" val={replay.duration.asSeconds()} />
  478. ) : null}
  479. </Container>
  480. </Item>
  481. );
  482. }
  483. export function RageClickCountCell({replay, showDropdownFilters}: Props) {
  484. if (replay.is_archived) {
  485. return <Item isArchived />;
  486. }
  487. return (
  488. <Item data-test-id="replay-table-count-rage-clicks">
  489. <Container>
  490. {replay.count_rage_clicks ? (
  491. <RageClickCount>
  492. <IconCursorArrow size="sm" color="red300" />
  493. {replay.count_rage_clicks}
  494. </RageClickCount>
  495. ) : (
  496. <Count>0</Count>
  497. )}
  498. {showDropdownFilters ? (
  499. <NumericDropdownFilter
  500. type="count_rage_clicks"
  501. val={replay.count_rage_clicks ?? 0}
  502. />
  503. ) : null}
  504. </Container>
  505. </Item>
  506. );
  507. }
  508. export function DeadClickCountCell({replay, showDropdownFilters}: Props) {
  509. if (replay.is_archived) {
  510. return <Item isArchived />;
  511. }
  512. return (
  513. <Item data-test-id="replay-table-count-dead-clicks">
  514. <Container>
  515. {replay.count_dead_clicks ? (
  516. <DeadClickCount>
  517. <IconCursorArrow size="sm" color="yellow300" />
  518. {replay.count_dead_clicks}
  519. </DeadClickCount>
  520. ) : (
  521. <Count>0</Count>
  522. )}
  523. {showDropdownFilters ? (
  524. <NumericDropdownFilter
  525. type="count_dead_clicks"
  526. val={replay.count_dead_clicks ?? 0}
  527. />
  528. ) : null}
  529. </Container>
  530. </Item>
  531. );
  532. }
  533. export function ErrorCountCell({replay, showDropdownFilters}: Props) {
  534. if (replay.is_archived) {
  535. return <Item isArchived />;
  536. }
  537. return (
  538. <Item data-test-id="replay-table-count-errors">
  539. <Container>
  540. {replay.count_errors ? (
  541. <ErrorCount>
  542. <IconFire color="red300" />
  543. {replay.count_errors}
  544. </ErrorCount>
  545. ) : (
  546. <Count>0</Count>
  547. )}
  548. {showDropdownFilters ? (
  549. <NumericDropdownFilter type="count_errors" val={replay.count_errors ?? 0} />
  550. ) : null}
  551. </Container>
  552. </Item>
  553. );
  554. }
  555. export function ActivityCell({replay, showDropdownFilters}: Props) {
  556. if (replay.is_archived) {
  557. return <Item isArchived />;
  558. }
  559. const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
  560. return (
  561. <Item>
  562. <Container>
  563. <ScoreBar
  564. size={20}
  565. score={replay?.activity ?? 1}
  566. palette={scoreBarPalette}
  567. radius={0}
  568. />
  569. {showDropdownFilters ? (
  570. <NumericDropdownFilter
  571. type="activity"
  572. val={replay?.activity ?? 0}
  573. triggerOverlay
  574. />
  575. ) : null}
  576. </Container>
  577. </Item>
  578. );
  579. }
  580. const Item = styled('div')<{
  581. isArchived?: boolean;
  582. isReplayCell?: boolean;
  583. isWidget?: boolean;
  584. }>`
  585. display: flex;
  586. align-items: center;
  587. gap: ${space(1)};
  588. ${p =>
  589. p.isWidget
  590. ? `padding: ${space(0.75)} ${space(1.5)} ${space(1.5)} ${space(1.5)};`
  591. : `padding: ${space(1.5)};`};
  592. ${p => (p.isArchived ? 'opacity: 0.5;' : '')};
  593. ${p => (p.isReplayCell ? 'overflow: auto;' : '')};
  594. `;
  595. const Count = styled('span')`
  596. font-variant-numeric: tabular-nums;
  597. `;
  598. const DeadClickCount = styled(Count)`
  599. display: flex;
  600. width: 40px;
  601. gap: ${space(0.5)};
  602. `;
  603. const RageClickCount = styled(Count)`
  604. display: flex;
  605. width: 40px;
  606. gap: ${space(0.5)};
  607. `;
  608. const ErrorCount = styled(Count)`
  609. display: flex;
  610. align-items: center;
  611. gap: ${space(0.5)};
  612. `;
  613. const Time = styled('span')`
  614. font-variant-numeric: tabular-nums;
  615. `;
  616. const SpanOperationBreakdown = styled('div')`
  617. width: 100%;
  618. display: flex;
  619. flex-direction: column;
  620. gap: ${space(0.5)};
  621. color: ${p => p.theme.gray500};
  622. font-size: ${p => p.theme.fontSizeMedium};
  623. text-align: right;
  624. `;
  625. const Container = styled('div')`
  626. position: relative;
  627. display: flex;
  628. flex-direction: column;
  629. justify-content: center;
  630. `;
  631. const ActionMenuTrigger = styled(Button)`
  632. position: absolute;
  633. top: 50%;
  634. transform: translateY(-50%);
  635. padding: ${space(0.75)};
  636. left: -${space(0.75)};
  637. display: flex;
  638. align-items: center;
  639. opacity: 0;
  640. transition: opacity 0.1s;
  641. &:focus-visible,
  642. &[aria-expanded='true'],
  643. ${Container}:hover & {
  644. opacity: 1;
  645. }
  646. `;
  647. const NumericActionMenuTrigger = styled(ActionMenuTrigger)`
  648. left: 100%;
  649. margin-left: ${space(0.75)};
  650. z-index: 1;
  651. `;
  652. const OverlayActionMenuTrigger = styled(NumericActionMenuTrigger)`
  653. right: 0%;
  654. left: unset;
  655. `;