tableCell.tsx 19 KB

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