eventNavigation.tsx 13 KB

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