eventNavigation.tsx 14 KB

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