eventNavigation.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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. export function IssueEventNavigation({event, group}: IssueEventNavigationProps) {
  33. const organization = useOrganization();
  34. const {baseUrl, currentTab} = useGroupDetailsRoute();
  35. const location = useLocation();
  36. const eventView = useIssueDetailsEventView({group});
  37. const {eventCount} = useIssueDetails();
  38. const issueTypeConfig = getConfigForIssueType(group, group.project);
  39. const hideDropdownButton =
  40. !issueTypeConfig.attachments.enabled &&
  41. !issueTypeConfig.userFeedback.enabled &&
  42. !issueTypeConfig.replays.enabled;
  43. const discoverUrl = eventView.getResultsViewUrlTarget(
  44. organization.slug,
  45. false,
  46. hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
  47. );
  48. const {getReplayCountForIssue} = useReplayCountForIssues({
  49. statsPeriod: '90d',
  50. });
  51. const replaysCount = getReplayCountForIssue(group.id, group.issueCategory) ?? 0;
  52. const attachments = useGroupEventAttachments({
  53. group,
  54. activeAttachmentsTab: 'all',
  55. options: {placeholderData: keepPreviousData},
  56. });
  57. const attachmentPagination = parseLinkHeader(
  58. attachments.getResponseHeader?.('Link') ?? null
  59. );
  60. // Since we reuse whatever page the user was on, we can look at pagination to determine if there are more attachments
  61. const hasManyAttachments =
  62. attachmentPagination.next?.results || attachmentPagination.previous?.results;
  63. const TabName: Partial<Record<Tab, string>> = {
  64. [Tab.DETAILS]: issueTypeConfig.customCopy.eventUnits,
  65. [Tab.EVENTS]: issueTypeConfig.customCopy.eventUnits,
  66. [Tab.REPLAYS]: t('Replays'),
  67. [Tab.ATTACHMENTS]: t('Attachments'),
  68. [Tab.USER_FEEDBACK]: t('Feedback'),
  69. };
  70. const allEventsPath = `${baseUrl}${TabPaths[issueTypeConfig.allEventsPath]}`;
  71. return (
  72. <EventNavigationWrapper role="navigation">
  73. <LargeDropdownButtonWrapper>
  74. <DropdownMenu
  75. onAction={key => {
  76. trackAnalytics('issue_details.issue_content_selected', {
  77. organization,
  78. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  79. content: TabName[key],
  80. });
  81. }}
  82. items={[
  83. {
  84. key: Tab.DETAILS,
  85. label: (
  86. <DropdownCountWrapper isCurrentTab={currentTab === Tab.DETAILS}>
  87. {TabName[Tab.DETAILS]} <ItemCount value={eventCount ?? 0} />
  88. </DropdownCountWrapper>
  89. ),
  90. textValue: TabName[Tab.DETAILS],
  91. to: {
  92. ...location,
  93. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  94. },
  95. },
  96. {
  97. key: Tab.REPLAYS,
  98. label: (
  99. <DropdownCountWrapper isCurrentTab={currentTab === Tab.REPLAYS}>
  100. {TabName[Tab.REPLAYS]}{' '}
  101. {replaysCount > 50 ? (
  102. <CustomItemCount>50+</CustomItemCount>
  103. ) : (
  104. <ItemCount value={replaysCount} />
  105. )}
  106. </DropdownCountWrapper>
  107. ),
  108. textValue: TabName[Tab.REPLAYS],
  109. to: {
  110. ...location,
  111. pathname: `${baseUrl}${TabPaths[Tab.REPLAYS]}`,
  112. },
  113. hidden: !issueTypeConfig.replays.enabled,
  114. },
  115. {
  116. key: Tab.ATTACHMENTS,
  117. label: (
  118. <DropdownCountWrapper isCurrentTab={currentTab === Tab.ATTACHMENTS}>
  119. {TabName[Tab.ATTACHMENTS]}
  120. <CustomItemCount>
  121. {hasManyAttachments ? '50+' : attachments.attachments.length}
  122. </CustomItemCount>
  123. </DropdownCountWrapper>
  124. ),
  125. textValue: TabName[Tab.ATTACHMENTS],
  126. to: {
  127. ...location,
  128. pathname: `${baseUrl}${TabPaths[Tab.ATTACHMENTS]}`,
  129. },
  130. hidden: !issueTypeConfig.attachments.enabled,
  131. },
  132. {
  133. key: Tab.USER_FEEDBACK,
  134. label: (
  135. <DropdownCountWrapper isCurrentTab={currentTab === Tab.USER_FEEDBACK}>
  136. {TabName[Tab.USER_FEEDBACK]} <ItemCount value={group.userReportCount} />
  137. </DropdownCountWrapper>
  138. ),
  139. textValue: TabName[Tab.USER_FEEDBACK],
  140. to: {
  141. ...location,
  142. pathname: `${baseUrl}${TabPaths[Tab.USER_FEEDBACK]}`,
  143. },
  144. hidden: !issueTypeConfig.userFeedback.enabled,
  145. },
  146. ]}
  147. offset={[-2, 1]}
  148. trigger={(triggerProps, isOpen) =>
  149. hideDropdownButton ? (
  150. <NavigationLabel>
  151. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  152. </NavigationLabel>
  153. ) : (
  154. <NavigationDropdownButton
  155. {...triggerProps}
  156. isOpen={isOpen}
  157. borderless
  158. size="sm"
  159. disabled={hideDropdownButton}
  160. aria-label={t('Select issue content')}
  161. aria-description={TabName[currentTab]}
  162. analyticsEventName="Issue Details: Issue Content Dropdown Opened"
  163. analyticsEventKey="issue_details.issue_content_dropdown_opened"
  164. >
  165. {TabName[currentTab] ?? TabName[Tab.DETAILS]}
  166. </NavigationDropdownButton>
  167. )
  168. }
  169. />
  170. <LargeInThisIssueText aria-hidden>{t('in this issue')}</LargeInThisIssueText>
  171. </LargeDropdownButtonWrapper>
  172. <NavigationWrapper>
  173. {currentTab === Tab.DETAILS && (
  174. <Fragment>
  175. <IssueDetailsEventNavigation event={event} group={group} />
  176. <LinkButton
  177. to={{
  178. pathname: allEventsPath,
  179. query: location.query,
  180. }}
  181. size="xs"
  182. analyticsEventKey="issue_details.all_events_clicked"
  183. analyticsEventName="Issue Details: All Events Clicked"
  184. >
  185. {t('All %s', issueTypeConfig.customCopy.eventUnits)}
  186. </LinkButton>
  187. </Fragment>
  188. )}
  189. {currentTab === issueTypeConfig.allEventsPath && (
  190. <ButtonBar gap={1}>
  191. {currentTab === Tab.EVENTS && (
  192. <LinkButton
  193. to={discoverUrl}
  194. aria-label={t('Open in Discover')}
  195. size="xs"
  196. icon={<IconTelescope />}
  197. analyticsEventKey="issue_details.discover_clicked"
  198. analyticsEventName="Issue Details: Discover Clicked"
  199. >
  200. {t('Discover')}
  201. </LinkButton>
  202. )}
  203. <LinkButton
  204. to={{
  205. pathname: `${baseUrl}${TabPaths[Tab.DETAILS]}`,
  206. query: {...location.query, cursor: undefined},
  207. }}
  208. aria-label={t('Return to event details')}
  209. size="xs"
  210. >
  211. {t('Close')}
  212. </LinkButton>
  213. </ButtonBar>
  214. )}
  215. </NavigationWrapper>
  216. </EventNavigationWrapper>
  217. );
  218. }
  219. const LargeDropdownButtonWrapper = styled('div')`
  220. display: flex;
  221. align-items: center;
  222. gap: ${space(0.25)};
  223. `;
  224. const NavigationDropdownButton = styled(DropdownButton)`
  225. font-size: ${p => p.theme.fontSizeLarge};
  226. font-weight: ${p => p.theme.fontWeightBold};
  227. padding-right: ${space(0.5)};
  228. `;
  229. const NavigationLabel = styled('div')`
  230. font-size: ${p => p.theme.fontSizeLarge};
  231. font-weight: ${p => p.theme.fontWeightBold};
  232. padding-right: ${space(0.25)};
  233. padding-left: ${space(1.5)};
  234. `;
  235. const LargeInThisIssueText = styled('div')`
  236. font-size: ${p => p.theme.fontSizeLarge};
  237. font-weight: ${p => p.theme.fontWeightBold};
  238. color: ${p => p.theme.subText};
  239. `;
  240. const EventNavigationWrapper = styled('div')`
  241. flex-grow: 1;
  242. display: flex;
  243. flex-direction: column;
  244. justify-content: space-between;
  245. font-size: ${p => p.theme.fontSizeSmall};
  246. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  247. flex-direction: row;
  248. align-items: center;
  249. }
  250. `;
  251. const NavigationWrapper = styled('div')`
  252. display: flex;
  253. gap: ${space(0.25)};
  254. justify-content: space-between;
  255. @media (min-width: ${p => p.theme.breakpoints.xsmall}) {
  256. gap: ${space(0.5)};
  257. }
  258. `;
  259. const DropdownCountWrapper = styled('div')<{isCurrentTab: boolean}>`
  260. display: flex;
  261. align-items: center;
  262. justify-content: space-between;
  263. gap: ${space(3)};
  264. font-variant-numeric: tabular-nums;
  265. font-weight: ${p =>
  266. p.isCurrentTab ? p.theme.fontWeightBold : p.theme.fontWeightNormal};
  267. `;
  268. const ItemCount = styled(Count)`
  269. color: ${p => p.theme.subText};
  270. `;
  271. const CustomItemCount = styled('div')`
  272. color: ${p => p.theme.subText};
  273. `;