tableCell.tsx 18 KB

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