eventNavigation.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {LinkButton} from 'sentry/components/button';
  4. import ButtonBar from 'sentry/components/buttonBar';
  5. import Count from 'sentry/components/count';
  6. import DropdownButton from 'sentry/components/dropdownButton';
  7. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  8. import {IconTelescope} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {Event} from 'sentry/types/event';
  12. import type {Group} from 'sentry/types/group';
  13. import {trackAnalytics} from 'sentry/utils/analytics';
  14. import {SavedQueryDatasets} from 'sentry/utils/discover/types';
  15. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  16. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  17. import {keepPreviousData} from 'sentry/utils/queryClient';
  18. import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues';
  19. import {useLocation} from 'sentry/utils/useLocation';
  20. import useOrganization from 'sentry/utils/useOrganization';
  21. import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
  22. import {useGroupEventAttachments} from 'sentry/views/issueDetails/groupEventAttachments/useGroupEventAttachments';
  23. import {useIssueDetails} from 'sentry/views/issueDetails/streamline/context';
  24. import {useIssueDetailsEventView} from 'sentry/views/issueDetails/streamline/hooks/useIssueDetailsDiscoverQuery';
  25. import {IssueDetailsEventNavigation} from 'sentry/views/issueDetails/streamline/issueDetailsEventNavigation';
  26. import {Tab, TabPaths} from 'sentry/views/issueDetails/types';
  27. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  28. interface IssueEventNavigationProps {
  29. event: Event | undefined;
  30. group: Group;
  31. }
  32. const LIST_VIEW_TABS = new Set([
  33. Tab.EVENTS,
  34. Tab.OPEN_PERIODS,
  35. Tab.CHECK_INS,
  36. Tab.UPTIME_CHECKS,
  37. ]);
  38. export function IssueEventNavigation({event, group}: IssueEventNavigationProps) {
  39. const organization = useOrganization();
  40. const {baseUrl, currentTab} = useGroupDetailsRoute();
  41. const location = useLocation();
  42. const eventView = useIssueDetailsEventView({group});
  43. const {eventCount} = useIssueDetails();
  44. const issueTypeConfig = getConfigForIssueType(group, group.project);
  45. const hideDropdownButton =
  46. !issueTypeConfig.pages.attachments.enabled &&
  47. !issueTypeConfig.pages.userFeedback.enabled &&
  48. !issueTypeConfig.pages.replays.enabled;
  49. const discoverUrl = eventView.getResultsViewUrlTarget(
  50. organization,
  51. false,
  52. hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
  53. );
  54. const {getReplayCountForIssue} = useReplayCountForIssues({
  55. statsPeriod: '90d',
  56. });
  57. const replaysCount = getReplayCountForIssue(group.id, group.issueCategory) ?? 0;
  58. const attachments = useGroupEventAttachments({
  59. group,
  60. activeAttachmentsTab: 'all',
  61. options: {placeholderData: keepPreviousData},
  62. });
  63. const attachmentPagination = parseLinkHeader(
  64. attachments.getResponseHeader?.('Link') ?? null
  65. );
  66. // Since we reuse whatever page the user was on, we can look at pagination to determine if there are more attachments
  67. const hasManyAttachments =
  68. attachmentPagination.next?.results || attachmentPagination.previous?.results;
  69. const TabName: Partial<Record<Tab, string>> = {
  70. [Tab.DETAILS]: issueTypeConfig.customCopy.eventUnits,
  71. [Tab.EVENTS]: issueTypeConfig.customCopy.eventUnits,
  72. [Tab.REPLAYS]: t('Replays'),
  73. [Tab.ATTACHMENTS]: t('Attachments'),
  74. [Tab.USER_FEEDBACK]: t('Feedback'),
  75. };
  76. const isListView = LIST_VIEW_TABS.has(currentTab);
  77. return (
  78. <EventNavigationWrapper role="navigation">
  79. <LargeDropdownButtonWrapper>
  80. <DropdownMenu
  81. onAction={key => {
  82. trackAnalytics('issue_details.issue_content_selected', {
  83. organization,
  84. content: TabName[key as keyof typeof TabName]!,
  85. });
  86. }}
  87. items={[
  88. {
  89. key: Tab.DETAILS,
  90. label: (
  91. <DropdownCountWrapper isCurrentTab={currentTab === Tab.DETAILS}>
  92. {TabName[Tab.DETAILS]} <ItemCount value={eventCount ?? 0} />
  93. </DropdownCountWrapper>
  94. ),
  95. textValue: TabName[Tab.DETAILS],
  96. to: {
  97. ...location,
  98. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  99. },
  100. },
  101. {
  102. key: Tab.REPLAYS,
  103. label: (
  104. <DropdownCountWrapper isCurrentTab={currentTab === Tab.REPLAYS}>
  105. {TabName[Tab.REPLAYS]}{' '}
  106. {replaysCount > 50 ? (
  107. <CustomItemCount>50+</CustomItemCount>
  108. ) : (
  109. <ItemCount value={replaysCount} />
  110. )}
  111. </DropdownCountWrapper>
  112. ),
  113. textValue: TabName[Tab.REPLAYS],
  114. to: {
  115. ...location,
  116. pathname: `${baseUrl}${TabPaths[Tab.REPLAYS]}`,
  117. },
  118. hidden: !issueTypeConfig.pages.replays.enabled,
  119. },
  120. {
  121. key: Tab.ATTACHMENTS,
  122. label: (
  123. <DropdownCountWrapper isCurrentTab={currentTab === Tab.ATTACHMENTS}>
  124. {TabName[Tab.ATTACHMENTS]}
  125. <CustomItemCount>
  126. {hasManyAttachments ? '50+' : attachments.attachments.length}
  127. </CustomItemCount>
  128. </DropdownCountWrapper>
  129. ),
  130. textValue: TabName[Tab.ATTACHMENTS],
  131. to: {
  132. ...location,
  133. pathname: `${baseUrl}${TabPaths[Tab.ATTACHMENTS]}`,
  134. },
  135. hidden: !issueTypeConfig.pages.attachments.enabled,
  136. },
  137. {
  138. key: Tab.USER_FEEDBACK,
  139. label: (
  140. <DropdownCountWrapper isCurrentTab={currentTab === Tab.USER_FEEDBACK}>
  141. {TabName[Tab.USER_FEEDBACK]} <ItemCount value={group.userReportCount} />
  142. </DropdownCountWrapper>
  143. ),
  144. textValue: TabName[Tab.USER_FEEDBACK],
  145. to: {
  146. ...location,
  147. pathname: `${baseUrl}${TabPaths[Tab.USER_FEEDBACK]}`,
  148. },
  149. hidden: !issueTypeConfig.pages.userFeedback.enabled,
  150. },
  151. ]}
  152. offset={[-2, 1]}
  153. trigger={(triggerProps, isOpen) =>
  154. hideDropdownButton ? (
  155. <NavigationLabel>
  156. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  157. </NavigationLabel>
  158. ) : (
  159. <NavigationDropdownButton
  160. {...triggerProps}
  161. isOpen={isOpen}
  162. borderless
  163. size="sm"
  164. disabled={hideDropdownButton}
  165. aria-label={t('Select issue content')}
  166. aria-description={TabName[currentTab]}
  167. analyticsEventName="Issue Details: Issue Content Dropdown Opened"
  168. analyticsEventKey="issue_details.issue_content_dropdown_opened"
  169. >
  170. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  171. </NavigationDropdownButton>
  172. )
  173. }
  174. />
  175. <LargeInThisIssueText aria-hidden>{t('in this issue')}</LargeInThisIssueText>
  176. </LargeDropdownButtonWrapper>
  177. <NavigationWrapper>
  178. {currentTab === Tab.DETAILS && (
  179. <Fragment>
  180. <IssueDetailsEventNavigation event={event} group={group} />
  181. {issueTypeConfig.pages.events.enabled && (
  182. <LinkButton
  183. to={{
  184. pathname: `${baseUrl}${TabPaths[Tab.EVENTS]}`,
  185. query: location.query,
  186. }}
  187. size="xs"
  188. analyticsEventKey="issue_details.all_events_clicked"
  189. analyticsEventName="Issue Details: All Events Clicked"
  190. >
  191. {t('View More %s', issueTypeConfig.customCopy.eventUnits)}
  192. </LinkButton>
  193. )}
  194. {issueTypeConfig.pages.openPeriods.enabled && (
  195. <LinkButton
  196. to={{
  197. pathname: `${baseUrl}${TabPaths[Tab.OPEN_PERIODS]}`,
  198. query: location.query,
  199. }}
  200. size="xs"
  201. analyticsEventKey="issue_details.all_open_periods_clicked"
  202. analyticsEventName="Issue Details: All Open Periods Clicked"
  203. >
  204. {t('View More Open Periods')}
  205. </LinkButton>
  206. )}
  207. {issueTypeConfig.pages.checkIns.enabled && (
  208. <LinkButton
  209. to={{
  210. pathname: `${baseUrl}${TabPaths[Tab.CHECK_INS]}`,
  211. query: location.query,
  212. }}
  213. size="xs"
  214. analyticsEventKey="issue_details.all_checks_ins_clicked"
  215. analyticsEventName="Issue Details: All Checks-Ins Clicked"
  216. >
  217. {t('View More Check-Ins')}
  218. </LinkButton>
  219. )}
  220. {issueTypeConfig.pages.uptimeChecks.enabled && (
  221. <LinkButton
  222. to={{
  223. pathname: `${baseUrl}${TabPaths[Tab.UPTIME_CHECKS]}`,
  224. query: location.query,
  225. }}
  226. size="xs"
  227. analyticsEventKey="issue_details.all_uptime_checks_clicked"
  228. analyticsEventName="Issue Details: All Uptime Checks Clicked"
  229. >
  230. {t('View More Uptime Checks')}
  231. </LinkButton>
  232. )}
  233. </Fragment>
  234. )}
  235. {isListView && (
  236. <ButtonBar gap={1}>
  237. {issueTypeConfig.discover.enabled && currentTab === Tab.EVENTS && (
  238. <LinkButton
  239. to={discoverUrl}
  240. aria-label={t('Open in Discover')}
  241. size="xs"
  242. icon={<IconTelescope />}
  243. analyticsEventKey="issue_details.discover_clicked"
  244. analyticsEventName="Issue Details: Discover Clicked"
  245. >
  246. {t('Open in Discover')}
  247. </LinkButton>
  248. )}
  249. <LinkButton
  250. to={{
  251. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  252. query: {...location.query, cursor: undefined},
  253. }}
  254. aria-label={t('Return to event details')}
  255. size="xs"
  256. >
  257. {t('Close')}
  258. </LinkButton>
  259. </ButtonBar>
  260. )}
  261. </NavigationWrapper>
  262. </EventNavigationWrapper>
  263. );
  264. }
  265. const LargeDropdownButtonWrapper = styled('div')`
  266. display: flex;
  267. align-items: center;
  268. gap: ${space(0.25)};
  269. `;
  270. const NavigationDropdownButton = styled(DropdownButton)`
  271. font-size: ${p => p.theme.fontSizeLarge};
  272. font-weight: ${p => p.theme.fontWeightBold};
  273. padding-right: ${space(0.5)};
  274. `;
  275. const NavigationLabel = styled('div')`
  276. font-size: ${p => p.theme.fontSizeLarge};
  277. font-weight: ${p => p.theme.fontWeightBold};
  278. padding-right: ${space(0.25)};
  279. padding-left: ${space(1.5)};
  280. `;
  281. const LargeInThisIssueText = styled('div')`
  282. font-size: ${p => p.theme.fontSizeLarge};
  283. font-weight: ${p => p.theme.fontWeightBold};
  284. color: ${p => p.theme.subText};
  285. `;
  286. const EventNavigationWrapper = styled('div')`
  287. flex-grow: 1;
  288. display: flex;
  289. flex-direction: column;
  290. justify-content: space-between;
  291. font-size: ${p => p.theme.fontSizeSmall};
  292. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  293. flex-direction: row;
  294. align-items: center;
  295. }
  296. `;
  297. const NavigationWrapper = styled('div')`
  298. display: flex;
  299. gap: ${space(0.25)};
  300. justify-content: space-between;
  301. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  302. gap: ${space(0.5)};
  303. }
  304. `;
  305. const DropdownCountWrapper = styled('div')<{isCurrentTab: boolean}>`
  306. display: flex;
  307. align-items: center;
  308. justify-content: space-between;
  309. gap: ${space(3)};
  310. font-variant-numeric: tabular-nums;
  311. font-weight: ${p =>
  312. p.isCurrentTab ? p.theme.fontWeightBold : p.theme.fontWeightNormal};
  313. `;
  314. const ItemCount = styled(Count)`
  315. color: ${p => p.theme.subText};
  316. `;
  317. const CustomItemCount = styled('div')`
  318. color: ${p => p.theme.subText};
  319. `;