tableCell.tsx 18 KB

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