tableCell.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  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 Duration from 'sentry/components/duration/duration';
  9. import Link from 'sentry/components/links/link';
  10. import PlatformIcon from 'sentry/components/replays/platformIcon';
  11. import ReplayPlayPauseButton from 'sentry/components/replays/replayPlayPauseButton';
  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. IconPlay,
  23. } from 'sentry/icons';
  24. import {t, tct} from 'sentry/locale';
  25. import type {ValidSize} from 'sentry/styles/space';
  26. import {space} from 'sentry/styles/space';
  27. import type {Organization} from 'sentry/types/organization';
  28. import {trackAnalytics} from 'sentry/utils/analytics';
  29. import {browserHistory} from 'sentry/utils/browserHistory';
  30. import type EventView from 'sentry/utils/discover/eventView';
  31. import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
  32. import {getShortEventId} from 'sentry/utils/events';
  33. import {decodeScalar} from 'sentry/utils/queryString';
  34. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  35. import {useLocation} from 'sentry/utils/useLocation';
  36. import useMedia from 'sentry/utils/useMedia';
  37. import useProjects from 'sentry/utils/useProjects';
  38. import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData';
  39. import {makeReplaysPathname} from 'sentry/views/replays/pathnames';
  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. const DEFAULT_NUMERIC_DROPDOWN_FORMATTER = (val: number) => val.toString();
  173. function NumericDropdownFilter({
  174. type,
  175. val,
  176. triggerOverlay,
  177. formatter = DEFAULT_NUMERIC_DROPDOWN_FORMATTER,
  178. }: {
  179. type: string;
  180. val: number;
  181. formatter?: (val: number) => string;
  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: formatter(val),
  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: '>' + formatter(val),
  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: '<' + formatter(val),
  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: formatter(val),
  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. referrer_table,
  282. isWidget,
  283. className,
  284. }: Props & {
  285. eventView: EventView;
  286. organization: Organization;
  287. referrer: string;
  288. className?: string;
  289. isWidget?: boolean;
  290. referrer_table?: ReferrerTableType;
  291. }) {
  292. const {projects} = useProjects();
  293. const project = projects.find(p => p.id === replay.project_id);
  294. const replayDetailsPathname = makeReplaysPathname({
  295. path: `/${replay.id}/`,
  296. organization,
  297. });
  298. const replayDetails = {
  299. pathname: replayDetailsPathname,
  300. query: {
  301. referrer,
  302. ...eventView.generateQueryStringObject(),
  303. },
  304. };
  305. const replayDetailsDeadRage = {
  306. pathname: replayDetailsPathname,
  307. query: {
  308. referrer,
  309. ...eventView.generateQueryStringObject(),
  310. f_b_type: 'rageOrDead',
  311. },
  312. };
  313. const detailsTab = () => {
  314. switch (referrer_table) {
  315. case 'selector-widget':
  316. return replayDetailsDeadRage;
  317. default:
  318. return replayDetails;
  319. }
  320. };
  321. const trackNavigationEvent = () =>
  322. trackAnalytics('replay.list-navigate-to-details', {
  323. project_id: project?.id,
  324. platform: project?.platform,
  325. organization,
  326. referrer,
  327. referrer_table,
  328. });
  329. if (replay.is_archived) {
  330. return (
  331. <Item isArchived={replay.is_archived} isReplayCell>
  332. <Row gap={1}>
  333. <StyledIconDelete color="gray500" size="md" />
  334. <div>
  335. <Row gap={0.5}>{t('Deleted Replay')}</Row>
  336. <Row gap={0.5}>
  337. {project ? <Avatar size={12} project={project} /> : null}
  338. <ArchivedId>{getShortEventId(replay.id)}</ArchivedId>
  339. </Row>
  340. </div>
  341. </Row>
  342. </Item>
  343. );
  344. }
  345. const subText = (
  346. <Cols>
  347. <Row gap={1}>
  348. <Row gap={0.5}>
  349. {/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */}
  350. {project ? <Avatar size={12} project={project} /> : null}
  351. {project ? project.slug : null}
  352. <Link to={detailsTab()} onClick={trackNavigationEvent}>
  353. {getShortEventId(replay.id)}
  354. </Link>
  355. <Row gap={0.5}>
  356. <IconCalendar color="gray300" size="xs" />
  357. <TimeSince date={replay.started_at} />
  358. </Row>
  359. </Row>
  360. </Row>
  361. </Cols>
  362. );
  363. return (
  364. <Item isWidget={isWidget} isReplayCell className={className}>
  365. <Row gap={1}>
  366. <UserAvatar user={getUserBadgeUser(replay)} size={24} />
  367. <SubText>
  368. <Row gap={0.5}>
  369. {replay.is_archived ? (
  370. replay.user.display_name || t('Anonymous User')
  371. ) : (
  372. <MainLink
  373. to={detailsTab()}
  374. onClick={trackNavigationEvent}
  375. data-has-viewed={replay.has_viewed}
  376. >
  377. {replay.user.display_name || t('Anonymous User')}
  378. </MainLink>
  379. )}
  380. </Row>
  381. <Row gap={0.5}>{subText}</Row>
  382. </SubText>
  383. </Row>
  384. </Item>
  385. );
  386. }
  387. const ArchivedId = styled('div')`
  388. font-size: ${p => p.theme.fontSizeSmall};
  389. `;
  390. const StyledIconDelete = styled(IconDelete)`
  391. margin: ${space(0.25)};
  392. `;
  393. const Cols = styled('div')`
  394. display: flex;
  395. flex-direction: column;
  396. gap: ${space(0.5)};
  397. width: 100%;
  398. `;
  399. const Row = styled('div')<{gap: ValidSize; minWidth?: number}>`
  400. display: flex;
  401. gap: ${p => space(p.gap)};
  402. align-items: center;
  403. ${p => (p.minWidth ? `min-width: ${p.minWidth}px;` : '')}
  404. `;
  405. const MainLink = styled(Link)`
  406. font-size: ${p => p.theme.fontSizeLarge};
  407. line-height: normal;
  408. ${p => p.theme.overflowEllipsis};
  409. font-weight: ${p => p.theme.fontWeightBold};
  410. &[data-has-viewed='true'] {
  411. font-weight: ${p => p.theme.fontWeightNormal};
  412. }
  413. `;
  414. const SubText = styled('div')`
  415. font-size: 0.875em;
  416. line-height: normal;
  417. color: ${p => p.theme.gray300};
  418. ${p => p.theme.overflowEllipsis};
  419. display: flex;
  420. flex-direction: column;
  421. gap: ${space(0.25)};
  422. `;
  423. export function TransactionCell({
  424. organization,
  425. replay,
  426. }: Props & {organization: Organization}) {
  427. const location = useLocation();
  428. if (replay.is_archived) {
  429. return <Item isArchived />;
  430. }
  431. const hasTxEvent = 'txEvent' in replay;
  432. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  433. return hasTxEvent ? (
  434. <Item>
  435. <SpanOperationBreakdown>
  436. {txDuration ? <div>{txDuration}ms</div> : null}
  437. {spanOperationRelativeBreakdownRenderer(
  438. replay.txEvent,
  439. {organization, location},
  440. {enableOnClick: false}
  441. )}
  442. </SpanOperationBreakdown>
  443. </Item>
  444. ) : null;
  445. }
  446. export function OSCell({replay, showDropdownFilters}: Props) {
  447. const {name, version} = replay.os ?? {};
  448. const theme = useTheme();
  449. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  450. if (replay.is_archived) {
  451. return <Item isArchived />;
  452. }
  453. return (
  454. <Item>
  455. <Container>
  456. <Tooltip title={`${name ?? ''} ${version ?? ''}`}>
  457. <PlatformIcon
  458. name={name ?? ''}
  459. version={version && hasRoomForColumns ? version : undefined}
  460. showVersion={false}
  461. showTooltip={false}
  462. />
  463. {showDropdownFilters ? (
  464. <OSBrowserDropdownFilter type="os" name={name} version={version} />
  465. ) : null}
  466. </Tooltip>
  467. </Container>
  468. </Item>
  469. );
  470. }
  471. export function BrowserCell({replay, showDropdownFilters}: Props) {
  472. const {name, version} = replay.browser ?? {};
  473. const theme = useTheme();
  474. const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.large})`);
  475. if (replay.is_archived) {
  476. return <Item isArchived />;
  477. }
  478. return (
  479. <Item>
  480. <Container>
  481. <Tooltip title={`${name} ${version}`}>
  482. <PlatformIcon
  483. name={name ?? ''}
  484. version={version && hasRoomForColumns ? version : undefined}
  485. showVersion={false}
  486. showTooltip={false}
  487. />
  488. {showDropdownFilters ? (
  489. <OSBrowserDropdownFilter type="browser" name={name} version={version} />
  490. ) : null}
  491. </Tooltip>
  492. </Container>
  493. </Item>
  494. );
  495. }
  496. export function DurationCell({replay, showDropdownFilters}: Props) {
  497. if (replay.is_archived) {
  498. return <Item isArchived />;
  499. }
  500. return (
  501. <Item>
  502. <Container>
  503. <Duration duration={[replay.duration.asMilliseconds(), 'ms']} precision="sec" />
  504. {showDropdownFilters ? (
  505. <NumericDropdownFilter
  506. type="duration"
  507. val={replay.duration.asSeconds()}
  508. formatter={(val: number) => `${val}s`}
  509. />
  510. ) : null}
  511. </Container>
  512. </Item>
  513. );
  514. }
  515. export function RageClickCountCell({replay, showDropdownFilters}: Props) {
  516. if (replay.is_archived) {
  517. return <Item isArchived />;
  518. }
  519. return (
  520. <Item data-test-id="replay-table-count-rage-clicks">
  521. <Container>
  522. {replay.count_rage_clicks ? (
  523. <RageClickCount>
  524. <IconCursorArrow size="sm" color="red300" />
  525. {replay.count_rage_clicks}
  526. </RageClickCount>
  527. ) : (
  528. <Count>0</Count>
  529. )}
  530. {showDropdownFilters ? (
  531. <NumericDropdownFilter
  532. type="count_rage_clicks"
  533. val={replay.count_rage_clicks ?? 0}
  534. />
  535. ) : null}
  536. </Container>
  537. </Item>
  538. );
  539. }
  540. export function DeadClickCountCell({replay, showDropdownFilters}: Props) {
  541. if (replay.is_archived) {
  542. return <Item isArchived />;
  543. }
  544. return (
  545. <Item data-test-id="replay-table-count-dead-clicks">
  546. <Container>
  547. {replay.count_dead_clicks ? (
  548. <DeadClickCount>
  549. <IconCursorArrow size="sm" color="yellow300" />
  550. {replay.count_dead_clicks}
  551. </DeadClickCount>
  552. ) : (
  553. <Count>0</Count>
  554. )}
  555. {showDropdownFilters ? (
  556. <NumericDropdownFilter
  557. type="count_dead_clicks"
  558. val={replay.count_dead_clicks ?? 0}
  559. />
  560. ) : null}
  561. </Container>
  562. </Item>
  563. );
  564. }
  565. export function ErrorCountCell({replay, showDropdownFilters}: Props) {
  566. if (replay.is_archived) {
  567. return <Item isArchived />;
  568. }
  569. return (
  570. <Item data-test-id="replay-table-count-errors">
  571. <Container>
  572. {replay.count_errors ? (
  573. <ErrorCount>
  574. <IconFire color="red300" />
  575. {replay.count_errors}
  576. </ErrorCount>
  577. ) : (
  578. <Count>0</Count>
  579. )}
  580. {showDropdownFilters ? (
  581. <NumericDropdownFilter type="count_errors" val={replay.count_errors ?? 0} />
  582. ) : null}
  583. </Container>
  584. </Item>
  585. );
  586. }
  587. export function ActivityCell({replay, showDropdownFilters}: Props) {
  588. if (replay.is_archived) {
  589. return <Item isArchived />;
  590. }
  591. const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
  592. return (
  593. <Item>
  594. <Container>
  595. <ScoreBar
  596. size={20}
  597. score={replay?.activity ?? 1}
  598. palette={scoreBarPalette}
  599. radius={0}
  600. />
  601. {showDropdownFilters ? (
  602. <NumericDropdownFilter
  603. type="activity"
  604. val={replay?.activity ?? 0}
  605. triggerOverlay
  606. />
  607. ) : null}
  608. </Container>
  609. </Item>
  610. );
  611. }
  612. export function PlayPauseCell({
  613. isSelected,
  614. handleClick,
  615. }: {
  616. handleClick: () => void;
  617. isSelected: boolean;
  618. }) {
  619. const inner = isSelected ? (
  620. <ReplayPlayPauseButton size="sm" priority="default" borderless />
  621. ) : (
  622. <Button
  623. title={t('Play')}
  624. aria-label={t('Play')}
  625. icon={<IconPlay size="sm" />}
  626. onClick={handleClick}
  627. data-test-id="replay-table-play-button"
  628. borderless
  629. size="sm"
  630. priority="default"
  631. />
  632. );
  633. return <Item>{inner}</Item>;
  634. }
  635. const Item = styled('div')<{
  636. isArchived?: boolean;
  637. isReplayCell?: boolean;
  638. isWidget?: boolean;
  639. }>`
  640. display: flex;
  641. align-items: center;
  642. gap: ${space(1)};
  643. ${p =>
  644. p.isWidget
  645. ? `padding: ${space(0.75)} ${space(1.5)} ${space(1.5)} ${space(1.5)};`
  646. : `padding: ${space(1.5)};`};
  647. ${p => (p.isArchived ? 'opacity: 0.5;' : '')};
  648. ${p => (p.isReplayCell ? 'overflow: auto;' : '')};
  649. `;
  650. const Count = styled('span')`
  651. font-variant-numeric: tabular-nums;
  652. `;
  653. const DeadClickCount = styled(Count)`
  654. display: flex;
  655. width: 40px;
  656. gap: ${space(0.5)};
  657. `;
  658. const RageClickCount = styled(Count)`
  659. display: flex;
  660. width: 40px;
  661. gap: ${space(0.5)};
  662. `;
  663. const ErrorCount = styled(Count)`
  664. display: flex;
  665. align-items: center;
  666. gap: ${space(0.5)};
  667. `;
  668. const SpanOperationBreakdown = styled('div')`
  669. width: 100%;
  670. display: flex;
  671. flex-direction: column;
  672. gap: ${space(0.5)};
  673. color: ${p => p.theme.gray500};
  674. font-size: ${p => p.theme.fontSizeMedium};
  675. text-align: right;
  676. `;
  677. const Container = styled('div')`
  678. position: relative;
  679. display: flex;
  680. flex-direction: column;
  681. justify-content: center;
  682. `;
  683. const ActionMenuTrigger = styled(Button)`
  684. position: absolute;
  685. top: 50%;
  686. transform: translateY(-50%);
  687. padding: ${space(0.75)};
  688. left: -${space(0.75)};
  689. display: flex;
  690. align-items: center;
  691. opacity: 0;
  692. transition: opacity 0.1s;
  693. &:focus-visible,
  694. &[aria-expanded='true'],
  695. ${Container}:hover & {
  696. opacity: 1;
  697. }
  698. `;
  699. const NumericActionMenuTrigger = styled(ActionMenuTrigger)`
  700. left: 100%;
  701. margin-left: ${space(0.75)};
  702. z-index: 1;
  703. `;
  704. const OverlayActionMenuTrigger = styled(NumericActionMenuTrigger)`
  705. right: 0%;
  706. left: unset;
  707. `;