eventNavigation.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  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. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  85. content: TabName[key],
  86. });
  87. }}
  88. items={[
  89. {
  90. key: Tab.DETAILS,
  91. label: (
  92. <DropdownCountWrapper isCurrentTab={currentTab === Tab.DETAILS}>
  93. {TabName[Tab.DETAILS]} <ItemCount value={eventCount ?? 0} />
  94. </DropdownCountWrapper>
  95. ),
  96. textValue: TabName[Tab.DETAILS],
  97. to: {
  98. ...location,
  99. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  100. },
  101. },
  102. {
  103. key: Tab.REPLAYS,
  104. label: (
  105. <DropdownCountWrapper isCurrentTab={currentTab === Tab.REPLAYS}>
  106. {TabName[Tab.REPLAYS]}{' '}
  107. {replaysCount > 50 ? (
  108. <CustomItemCount>50+</CustomItemCount>
  109. ) : (
  110. <ItemCount value={replaysCount} />
  111. )}
  112. </DropdownCountWrapper>
  113. ),
  114. textValue: TabName[Tab.REPLAYS],
  115. to: {
  116. ...location,
  117. pathname: `${baseUrl}${TabPaths[Tab.REPLAYS]}`,
  118. },
  119. hidden: !issueTypeConfig.pages.replays.enabled,
  120. },
  121. {
  122. key: Tab.ATTACHMENTS,
  123. label: (
  124. <DropdownCountWrapper isCurrentTab={currentTab === Tab.ATTACHMENTS}>
  125. {TabName[Tab.ATTACHMENTS]}
  126. <CustomItemCount>
  127. {hasManyAttachments ? '50+' : attachments.attachments.length}
  128. </CustomItemCount>
  129. </DropdownCountWrapper>
  130. ),
  131. textValue: TabName[Tab.ATTACHMENTS],
  132. to: {
  133. ...location,
  134. pathname: `${baseUrl}${TabPaths[Tab.ATTACHMENTS]}`,
  135. },
  136. hidden: !issueTypeConfig.pages.attachments.enabled,
  137. },
  138. {
  139. key: Tab.USER_FEEDBACK,
  140. label: (
  141. <DropdownCountWrapper isCurrentTab={currentTab === Tab.USER_FEEDBACK}>
  142. {TabName[Tab.USER_FEEDBACK]} <ItemCount value={group.userReportCount} />
  143. </DropdownCountWrapper>
  144. ),
  145. textValue: TabName[Tab.USER_FEEDBACK],
  146. to: {
  147. ...location,
  148. pathname: `${baseUrl}${TabPaths[Tab.USER_FEEDBACK]}`,
  149. },
  150. hidden: !issueTypeConfig.pages.userFeedback.enabled,
  151. },
  152. ]}
  153. offset={[-2, 1]}
  154. trigger={(triggerProps, isOpen) =>
  155. hideDropdownButton ? (
  156. <NavigationLabel>
  157. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  158. </NavigationLabel>
  159. ) : (
  160. <NavigationDropdownButton
  161. {...triggerProps}
  162. isOpen={isOpen}
  163. borderless
  164. size="sm"
  165. disabled={hideDropdownButton}
  166. aria-label={t('Select issue content')}
  167. aria-description={TabName[currentTab]}
  168. analyticsEventName="Issue Details: Issue Content Dropdown Opened"
  169. analyticsEventKey="issue_details.issue_content_dropdown_opened"
  170. >
  171. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  172. </NavigationDropdownButton>
  173. )
  174. }
  175. />
  176. <LargeInThisIssueText aria-hidden>{t('in this issue')}</LargeInThisIssueText>
  177. </LargeDropdownButtonWrapper>
  178. <NavigationWrapper>
  179. {currentTab === Tab.DETAILS && (
  180. <Fragment>
  181. <IssueDetailsEventNavigation event={event} group={group} />
  182. {issueTypeConfig.pages.events.enabled && (
  183. <LinkButton
  184. to={{
  185. pathname: `${baseUrl}${TabPaths[Tab.EVENTS]}`,
  186. query: location.query,
  187. }}
  188. size="xs"
  189. analyticsEventKey="issue_details.all_events_clicked"
  190. analyticsEventName="Issue Details: All Events Clicked"
  191. >
  192. {t('All %s', issueTypeConfig.customCopy.eventUnits)}
  193. </LinkButton>
  194. )}
  195. {issueTypeConfig.pages.openPeriods.enabled && (
  196. <LinkButton
  197. to={{
  198. pathname: `${baseUrl}${TabPaths[Tab.OPEN_PERIODS]}`,
  199. query: location.query,
  200. }}
  201. size="xs"
  202. analyticsEventKey="issue_details.all_open_periods_clicked"
  203. analyticsEventName="Issue Details: All Open Periods Clicked"
  204. >
  205. {t('All Open Periods')}
  206. </LinkButton>
  207. )}
  208. {issueTypeConfig.pages.checkIns.enabled && (
  209. <LinkButton
  210. to={{
  211. pathname: `${baseUrl}${TabPaths[Tab.CHECK_INS]}`,
  212. query: location.query,
  213. }}
  214. size="xs"
  215. analyticsEventKey="issue_details.all_checks_ins_clicked"
  216. analyticsEventName="Issue Details: All Checks-Ins Clicked"
  217. >
  218. {t('All Check-Ins')}
  219. </LinkButton>
  220. )}
  221. {issueTypeConfig.pages.uptimeChecks.enabled && (
  222. <LinkButton
  223. to={{
  224. pathname: `${baseUrl}${TabPaths[Tab.UPTIME_CHECKS]}`,
  225. query: location.query,
  226. }}
  227. size="xs"
  228. analyticsEventKey="issue_details.all_uptime_checks_clicked"
  229. analyticsEventName="Issue Details: All Uptime Checks Clicked"
  230. >
  231. {t('All Uptime Checks')}
  232. </LinkButton>
  233. )}
  234. </Fragment>
  235. )}
  236. {isListView && (
  237. <ButtonBar gap={1}>
  238. {issueTypeConfig.discover.enabled && currentTab === Tab.EVENTS && (
  239. <LinkButton
  240. to={discoverUrl}
  241. aria-label={t('Open in Discover')}
  242. size="xs"
  243. icon={<IconTelescope />}
  244. analyticsEventKey="issue_details.discover_clicked"
  245. analyticsEventName="Issue Details: Discover Clicked"
  246. >
  247. {t('Discover')}
  248. </LinkButton>
  249. )}
  250. <LinkButton
  251. to={{
  252. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  253. query: {...location.query, cursor: undefined},
  254. }}
  255. aria-label={t('Return to event details')}
  256. size="xs"
  257. >
  258. {t('Close')}
  259. </LinkButton>
  260. </ButtonBar>
  261. )}
  262. </NavigationWrapper>
  263. </EventNavigationWrapper>
  264. );
  265. }
  266. const LargeDropdownButtonWrapper = styled('div')`
  267. display: flex;
  268. align-items: center;
  269. gap: ${space(0.25)};
  270. `;
  271. const NavigationDropdownButton = styled(DropdownButton)`
  272. font-size: ${p => p.theme.fontSizeLarge};
  273. font-weight: ${p => p.theme.fontWeightBold};
  274. padding-right: ${space(0.5)};
  275. `;
  276. const NavigationLabel = styled('div')`
  277. font-size: ${p => p.theme.fontSizeLarge};
  278. font-weight: ${p => p.theme.fontWeightBold};
  279. padding-right: ${space(0.25)};
  280. padding-left: ${space(1.5)};
  281. `;
  282. const LargeInThisIssueText = styled('div')`
  283. font-size: ${p => p.theme.fontSizeLarge};
  284. font-weight: ${p => p.theme.fontWeightBold};
  285. color: ${p => p.theme.subText};
  286. `;
  287. const EventNavigationWrapper = styled('div')`
  288. flex-grow: 1;
  289. display: flex;
  290. flex-direction: column;
  291. justify-content: space-between;
  292. font-size: ${p => p.theme.fontSizeSmall};
  293. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  294. flex-direction: row;
  295. align-items: center;
  296. }
  297. `;
  298. const NavigationWrapper = styled('div')`
  299. display: flex;
  300. gap: ${space(0.25)};
  301. justify-content: space-between;
  302. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  303. gap: ${space(0.5)};
  304. }
  305. `;
  306. const DropdownCountWrapper = styled('div')<{isCurrentTab: boolean}>`
  307. display: flex;
  308. align-items: center;
  309. justify-content: space-between;
  310. gap: ${space(3)};
  311. font-variant-numeric: tabular-nums;
  312. font-weight: ${p =>
  313. p.isCurrentTab ? p.theme.fontWeightBold : p.theme.fontWeightNormal};
  314. `;
  315. const ItemCount = styled(Count)`
  316. color: ${p => p.theme.subText};
  317. `;
  318. const CustomItemCount = styled('div')`
  319. color: ${p => p.theme.subText};
  320. `;