eventNavigation.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import {css, useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {LinkButton} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import Count from 'sentry/components/count';
  7. import DropdownButton from 'sentry/components/dropdownButton';
  8. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  9. import {TabList, Tabs} from 'sentry/components/tabs';
  10. import {Tooltip} from 'sentry/components/tooltip';
  11. import {IconChevron, IconTelescope} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {Event} from 'sentry/types/event';
  15. import type {Group} from 'sentry/types/group';
  16. import {defined} from 'sentry/utils';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import {SavedQueryDatasets} from 'sentry/utils/discover/types';
  19. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  20. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  21. import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
  22. import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues';
  23. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  24. import {useLocation} from 'sentry/utils/useLocation';
  25. import useMedia from 'sentry/utils/useMedia';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {useParams} from 'sentry/utils/useParams';
  28. import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
  29. import {useGroupEventAttachments} from 'sentry/views/issueDetails/groupEventAttachments/useGroupEventAttachments';
  30. import {useIssueDetails} from 'sentry/views/issueDetails/streamline/context';
  31. import {useIssueDetailsEventView} from 'sentry/views/issueDetails/streamline/hooks/useIssueDetailsDiscoverQuery';
  32. import {Tab, TabPaths} from 'sentry/views/issueDetails/types';
  33. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  34. import {
  35. getGroupEventQueryKey,
  36. useDefaultIssueEvent,
  37. useEnvironmentsFromUrl,
  38. } from 'sentry/views/issueDetails/utils';
  39. const enum EventNavOptions {
  40. RECOMMENDED = 'recommended',
  41. LATEST = 'latest',
  42. OLDEST = 'oldest',
  43. CUSTOM = 'custom',
  44. }
  45. const EventNavOrder = [
  46. EventNavOptions.OLDEST,
  47. EventNavOptions.LATEST,
  48. EventNavOptions.RECOMMENDED,
  49. EventNavOptions.CUSTOM,
  50. ];
  51. const TabName = {
  52. [Tab.DETAILS]: t('Events'),
  53. [Tab.EVENTS]: t('Events'),
  54. [Tab.REPLAYS]: t('Replays'),
  55. [Tab.ATTACHMENTS]: t('Attachments'),
  56. [Tab.USER_FEEDBACK]: t('Feedback'),
  57. };
  58. interface IssueEventNavigationProps {
  59. event: Event | undefined;
  60. group: Group;
  61. }
  62. export function IssueEventNavigation({event, group}: IssueEventNavigationProps) {
  63. const theme = useTheme();
  64. const organization = useOrganization();
  65. const {baseUrl, currentTab} = useGroupDetailsRoute();
  66. const location = useLocation();
  67. const params = useParams<{eventId?: string}>();
  68. const defaultIssueEvent = useDefaultIssueEvent();
  69. const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.small})`);
  70. const [shouldPreload, setShouldPreload] = useState({next: false, previous: false});
  71. const environments = useEnvironmentsFromUrl();
  72. const eventView = useIssueDetailsEventView({group});
  73. const {eventCount} = useIssueDetails();
  74. const issueTypeConfig = getConfigForIssueType(group, group.project);
  75. const hideDropdownButton =
  76. !issueTypeConfig.attachments.enabled &&
  77. !issueTypeConfig.userFeedback.enabled &&
  78. !issueTypeConfig.replays.enabled;
  79. const discoverUrl = eventView.getResultsViewUrlTarget(
  80. organization.slug,
  81. false,
  82. hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
  83. );
  84. // Reset shouldPreload when the groupId changes
  85. useEffect(() => {
  86. setShouldPreload({next: false, previous: false});
  87. }, [group.id]);
  88. const handleHoverPagination = useCallback(
  89. (direction: 'next' | 'previous', isEnabled: boolean) => () => {
  90. if (isEnabled) {
  91. setShouldPreload(prev => ({...prev, [direction]: true}));
  92. }
  93. },
  94. []
  95. );
  96. // Prefetch next
  97. useApiQuery(
  98. getGroupEventQueryKey({
  99. orgSlug: organization.slug,
  100. groupId: group.id,
  101. // Will be defined when enabled
  102. eventId: event?.nextEventID!,
  103. environments,
  104. }),
  105. {
  106. enabled: shouldPreload.next && defined(event?.nextEventID),
  107. staleTime: Infinity,
  108. // Ignore state changes from the query
  109. notifyOnChangeProps: [],
  110. }
  111. );
  112. // Prefetch previous
  113. useApiQuery(
  114. getGroupEventQueryKey({
  115. orgSlug: organization.slug,
  116. groupId: group.id,
  117. // Will be defined when enabled
  118. eventId: event?.previousEventID!,
  119. environments,
  120. }),
  121. {
  122. enabled: shouldPreload.previous && defined(event?.previousEventID),
  123. staleTime: Infinity,
  124. // Ignore state changes from the query
  125. notifyOnChangeProps: [],
  126. }
  127. );
  128. const {getReplayCountForIssue} = useReplayCountForIssues({
  129. statsPeriod: '90d',
  130. });
  131. const replaysCount = getReplayCountForIssue(group.id, group.issueCategory) ?? 0;
  132. const attachments = useGroupEventAttachments({
  133. group,
  134. activeAttachmentsTab: 'all',
  135. options: {placeholderData: keepPreviousData},
  136. });
  137. const attachmentPagination = parseLinkHeader(
  138. attachments.getResponseHeader?.('Link') ?? null
  139. );
  140. // Since we reuse whatever page the user was on, we can look at pagination to determine if there are more attachments
  141. const hasManyAttachments =
  142. attachmentPagination.next?.results || attachmentPagination.previous?.results;
  143. const selectedOption = useMemo(() => {
  144. switch (params.eventId) {
  145. case EventNavOptions.RECOMMENDED:
  146. case EventNavOptions.LATEST:
  147. case EventNavOptions.OLDEST:
  148. return params.eventId;
  149. case undefined:
  150. return defaultIssueEvent;
  151. default:
  152. return EventNavOptions.CUSTOM;
  153. }
  154. }, [params.eventId, defaultIssueEvent]);
  155. const onTabChange = (tabKey: typeof selectedOption) => {
  156. trackAnalytics('issue_details.event_navigation_selected', {
  157. organization,
  158. content: EventNavLabels[tabKey],
  159. });
  160. };
  161. const baseEventsPath = `/organizations/${organization.slug}/issues/${group.id}/events/`;
  162. const grayText = css`
  163. color: ${theme.subText};
  164. font-weight: ${theme.fontWeightNormal};
  165. `;
  166. const EventNavLabels = {
  167. [EventNavOptions.RECOMMENDED]: isSmallScreen ? t('Rec.') : t('Recommended'),
  168. [EventNavOptions.OLDEST]: t('First'),
  169. [EventNavOptions.LATEST]: t('Last'),
  170. [EventNavOptions.CUSTOM]: t('Specific'),
  171. };
  172. return (
  173. <EventNavigationWrapper role="navigation">
  174. <LargeDropdownButtonWrapper>
  175. <DropdownMenu
  176. onAction={key => {
  177. trackAnalytics('issue_details.issue_content_selected', {
  178. organization,
  179. content: TabName[key],
  180. });
  181. }}
  182. items={[
  183. {
  184. key: Tab.DETAILS,
  185. label: (
  186. <DropdownCountWrapper isCurrentTab={currentTab === Tab.DETAILS}>
  187. {TabName[Tab.DETAILS]} <ItemCount value={eventCount ?? 0} />
  188. </DropdownCountWrapper>
  189. ),
  190. textValue: TabName[Tab.DETAILS],
  191. to: {
  192. ...location,
  193. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  194. },
  195. },
  196. {
  197. key: Tab.REPLAYS,
  198. label: (
  199. <DropdownCountWrapper isCurrentTab={currentTab === Tab.REPLAYS}>
  200. {TabName[Tab.REPLAYS]}{' '}
  201. {replaysCount > 50 ? (
  202. <CustomItemCount>50+</CustomItemCount>
  203. ) : (
  204. <ItemCount value={replaysCount} />
  205. )}
  206. </DropdownCountWrapper>
  207. ),
  208. textValue: TabName[Tab.REPLAYS],
  209. to: {
  210. ...location,
  211. pathname: `${baseUrl}${TabPaths[Tab.REPLAYS]}`,
  212. },
  213. hidden: !issueTypeConfig.replays.enabled,
  214. },
  215. {
  216. key: Tab.ATTACHMENTS,
  217. label: (
  218. <DropdownCountWrapper isCurrentTab={currentTab === Tab.ATTACHMENTS}>
  219. {TabName[Tab.ATTACHMENTS]}
  220. <CustomItemCount>
  221. {hasManyAttachments ? '50+' : attachments.attachments.length}
  222. </CustomItemCount>
  223. </DropdownCountWrapper>
  224. ),
  225. textValue: TabName[Tab.ATTACHMENTS],
  226. to: {
  227. ...location,
  228. pathname: `${baseUrl}${TabPaths[Tab.ATTACHMENTS]}`,
  229. },
  230. hidden: !issueTypeConfig.attachments.enabled,
  231. },
  232. {
  233. key: Tab.USER_FEEDBACK,
  234. label: (
  235. <DropdownCountWrapper isCurrentTab={currentTab === Tab.USER_FEEDBACK}>
  236. {TabName[Tab.USER_FEEDBACK]} <ItemCount value={group.userReportCount} />
  237. </DropdownCountWrapper>
  238. ),
  239. textValue: TabName[Tab.USER_FEEDBACK],
  240. to: {
  241. ...location,
  242. pathname: `${baseUrl}${TabPaths[Tab.USER_FEEDBACK]}`,
  243. },
  244. hidden: !issueTypeConfig.userFeedback.enabled,
  245. },
  246. ]}
  247. offset={[-2, 1]}
  248. trigger={(triggerProps, isOpen) =>
  249. hideDropdownButton ? (
  250. <NavigationLabel>
  251. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  252. </NavigationLabel>
  253. ) : (
  254. <NavigationDropdownButton
  255. {...triggerProps}
  256. isOpen={isOpen}
  257. borderless
  258. size="sm"
  259. disabled={hideDropdownButton}
  260. aria-label={t('Select issue content')}
  261. aria-description={TabName[currentTab]}
  262. analyticsEventName="Issue Details: Issue Content Dropdown Opened"
  263. analyticsEventKey="issue_details.issue_content_dropdown_opened"
  264. >
  265. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  266. </NavigationDropdownButton>
  267. )
  268. }
  269. />
  270. <LargeInThisIssueText aria-hidden>{t('in this issue')}</LargeInThisIssueText>
  271. </LargeDropdownButtonWrapper>
  272. <NavigationWrapper>
  273. {currentTab === Tab.DETAILS && (
  274. <Fragment>
  275. <Navigation>
  276. <Tooltip title={t('Previous Event')} skipWrapper>
  277. <LinkButton
  278. aria-label={t('Previous Event')}
  279. borderless
  280. size="xs"
  281. icon={<IconChevron direction="left" />}
  282. disabled={!defined(event?.previousEventID)}
  283. analyticsEventKey="issue_details.previous_event_clicked"
  284. analyticsEventName="Issue Details: Previous Event Clicked"
  285. to={{
  286. pathname: `${baseEventsPath}${event?.previousEventID}/`,
  287. query: {...location.query, referrer: 'previous-event'},
  288. }}
  289. css={grayText}
  290. onMouseEnter={handleHoverPagination(
  291. 'previous',
  292. defined(event?.previousEventID)
  293. )}
  294. onClick={() => {
  295. // Assume they will continue to paginate
  296. setShouldPreload({next: true, previous: true});
  297. }}
  298. />
  299. </Tooltip>
  300. <Tooltip title={t('Next Event')} skipWrapper>
  301. <LinkButton
  302. aria-label={t('Next Event')}
  303. borderless
  304. size="xs"
  305. icon={<IconChevron direction="right" />}
  306. disabled={!defined(event?.nextEventID)}
  307. analyticsEventKey="issue_details.next_event_clicked"
  308. analyticsEventName="Issue Details: Next Event Clicked"
  309. to={{
  310. pathname: `${baseEventsPath}${event?.nextEventID}/`,
  311. query: {...location.query, referrer: 'next-event'},
  312. }}
  313. css={grayText}
  314. onMouseEnter={handleHoverPagination(
  315. 'next',
  316. defined(event?.nextEventID)
  317. )}
  318. onClick={() => {
  319. // Assume they will continue to paginate
  320. setShouldPreload({next: true, previous: true});
  321. }}
  322. />
  323. </Tooltip>
  324. </Navigation>
  325. <Tabs value={selectedOption} disableOverflow onChange={onTabChange}>
  326. <TabList hideBorder variant="floating">
  327. {EventNavOrder.map(label => {
  328. const eventPath =
  329. label === selectedOption
  330. ? undefined
  331. : {
  332. pathname: normalizeUrl(baseEventsPath + label + '/'),
  333. query: {...location.query, referrer: `${label}-event`},
  334. };
  335. return (
  336. <TabList.Item
  337. to={eventPath}
  338. key={label}
  339. hidden={label === EventNavOptions.CUSTOM}
  340. textValue={EventNavLabels[label]}
  341. >
  342. {EventNavLabels[label]}
  343. </TabList.Item>
  344. );
  345. })}
  346. </TabList>
  347. </Tabs>
  348. </Fragment>
  349. )}
  350. {currentTab === Tab.DETAILS && (
  351. <LinkButton
  352. to={{
  353. pathname: `${baseUrl}${TabPaths[Tab.EVENTS]}`,
  354. query: location.query,
  355. }}
  356. size="xs"
  357. analyticsEventKey="issue_details.all_events_clicked"
  358. analyticsEventName="Issue Details: All Events Clicked"
  359. >
  360. {t('All Events')}
  361. </LinkButton>
  362. )}
  363. {currentTab === Tab.EVENTS && (
  364. <ButtonBar gap={1}>
  365. <LinkButton
  366. to={discoverUrl}
  367. aria-label={t('Open in Discover')}
  368. size="xs"
  369. icon={<IconTelescope />}
  370. analyticsEventKey="issue_details.discover_clicked"
  371. analyticsEventName="Issue Details: Discover Clicked"
  372. >
  373. {t('Discover')}
  374. </LinkButton>
  375. <LinkButton
  376. to={{
  377. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  378. query: {...location.query, cursor: undefined},
  379. }}
  380. aria-label={t('Return to event details')}
  381. size="xs"
  382. >
  383. {t('Close')}
  384. </LinkButton>
  385. </ButtonBar>
  386. )}
  387. </NavigationWrapper>
  388. </EventNavigationWrapper>
  389. );
  390. }
  391. const LargeDropdownButtonWrapper = styled('div')`
  392. display: flex;
  393. align-items: center;
  394. gap: ${space(0.25)};
  395. `;
  396. const NavigationDropdownButton = styled(DropdownButton)`
  397. font-size: ${p => p.theme.fontSizeLarge};
  398. font-weight: ${p => p.theme.fontWeightBold};
  399. padding-right: ${space(0.5)};
  400. `;
  401. const NavigationLabel = styled('div')`
  402. font-size: ${p => p.theme.fontSizeLarge};
  403. font-weight: ${p => p.theme.fontWeightBold};
  404. padding-right: ${space(0.25)};
  405. padding-left: ${space(1.5)};
  406. `;
  407. const LargeInThisIssueText = styled('div')`
  408. font-size: ${p => p.theme.fontSizeLarge};
  409. font-weight: ${p => p.theme.fontWeightBold};
  410. color: ${p => p.theme.subText};
  411. `;
  412. const EventNavigationWrapper = styled('div')`
  413. flex-grow: 1;
  414. display: flex;
  415. flex-direction: column;
  416. justify-content: space-between;
  417. font-size: ${p => p.theme.fontSizeSmall};
  418. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  419. flex-direction: row;
  420. align-items: center;
  421. }
  422. `;
  423. const NavigationWrapper = styled('div')`
  424. display: flex;
  425. gap: ${space(0.25)};
  426. justify-content: space-between;
  427. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  428. gap: ${space(0.5)};
  429. }
  430. `;
  431. const Navigation = styled('div')`
  432. display: flex;
  433. padding-right: ${space(0.25)};
  434. border-right: 1px solid ${p => p.theme.gray100};
  435. `;
  436. const DropdownCountWrapper = styled('div')<{isCurrentTab: boolean}>`
  437. display: flex;
  438. align-items: center;
  439. justify-content: space-between;
  440. gap: ${space(3)};
  441. font-variant-numeric: tabular-nums;
  442. font-weight: ${p =>
  443. p.isCurrentTab ? p.theme.fontWeightBold : p.theme.fontWeightNormal};
  444. `;
  445. const ItemCount = styled(Count)`
  446. color: ${p => p.theme.subText};
  447. `;
  448. const CustomItemCount = styled('div')`
  449. color: ${p => p.theme.subText};
  450. `;