tableCell.tsx 19 KB

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