eventNavigation.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import {type CSSProperties, forwardRef, Fragment, useMemo} from 'react';
  2. import {css, type SerializedStyles, useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {Button, LinkButton} from 'sentry/components/button';
  5. import DropdownButton from 'sentry/components/dropdownButton';
  6. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  7. import {useActionableItems} from 'sentry/components/events/interfaces/crashContent/exception/useActionableItems';
  8. import {ScrollCarousel} from 'sentry/components/scrollCarousel';
  9. import {TabList, Tabs} from 'sentry/components/tabs';
  10. import TimeSince from 'sentry/components/timeSince';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconChevron, IconWarning} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {Event} from 'sentry/types/event';
  16. import type {Group} from 'sentry/types/group';
  17. import {defined} from 'sentry/utils';
  18. import {trackAnalytics} from 'sentry/utils/analytics';
  19. import {
  20. getAnalyticsDataForEvent,
  21. getAnalyticsDataForGroup,
  22. getShortEventId,
  23. } from 'sentry/utils/events';
  24. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  25. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  26. import {useLocation} from 'sentry/utils/useLocation';
  27. import useMedia from 'sentry/utils/useMedia';
  28. import useOrganization from 'sentry/utils/useOrganization';
  29. import {useParams} from 'sentry/utils/useParams';
  30. import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
  31. import {Divider} from 'sentry/views/issueDetails/divider';
  32. import {
  33. type SectionConfig,
  34. SectionKey,
  35. useEventDetails,
  36. } from 'sentry/views/issueDetails/streamline/context';
  37. import {getFoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection';
  38. import {Tab, TabPaths} from 'sentry/views/issueDetails/types';
  39. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  40. import {useDefaultIssueEvent} from 'sentry/views/issueDetails/utils';
  41. export const MIN_NAV_HEIGHT = 44;
  42. type EventNavigationProps = {
  43. event: Event;
  44. group: Group;
  45. className?: string;
  46. /**
  47. * Data property to help style the component when it's sticky
  48. */
  49. 'data-stuck'?: boolean;
  50. query?: string;
  51. style?: CSSProperties;
  52. };
  53. enum EventNavOptions {
  54. RECOMMENDED = 'recommended',
  55. LATEST = 'latest',
  56. OLDEST = 'oldest',
  57. CUSTOM = 'custom',
  58. }
  59. const EventNavLabels = {
  60. [EventNavOptions.RECOMMENDED]: t('Recommended'),
  61. [EventNavOptions.OLDEST]: t('First'),
  62. [EventNavOptions.LATEST]: t('Last'),
  63. [EventNavOptions.CUSTOM]: t('Specific'),
  64. };
  65. const EventNavOrder = [
  66. EventNavOptions.RECOMMENDED,
  67. EventNavOptions.OLDEST,
  68. EventNavOptions.LATEST,
  69. EventNavOptions.CUSTOM,
  70. ];
  71. const sectionLabels = {
  72. [SectionKey.HIGHLIGHTS]: t('Highlights'),
  73. [SectionKey.STACKTRACE]: t('Stack Trace'),
  74. [SectionKey.TRACE]: t('Trace'),
  75. [SectionKey.EXCEPTION]: t('Stack Trace'),
  76. [SectionKey.BREADCRUMBS]: t('Breadcrumbs'),
  77. [SectionKey.TAGS]: t('Tags'),
  78. [SectionKey.CONTEXTS]: t('Context'),
  79. [SectionKey.USER_FEEDBACK]: t('User Feedback'),
  80. [SectionKey.REPLAY]: t('Replay'),
  81. [SectionKey.FEATURE_FLAGS]: t('Flags'),
  82. };
  83. export const EventNavigation = forwardRef<HTMLDivElement, EventNavigationProps>(
  84. function EventNavigation({event, group, query, ...props}, ref) {
  85. const location = useLocation();
  86. const organization = useOrganization();
  87. const theme = useTheme();
  88. const params = useParams<{eventId?: string}>();
  89. const defaultIssueEvent = useDefaultIssueEvent();
  90. const {sectionData} = useEventDetails();
  91. const eventSectionConfigs = Object.values(sectionData ?? {}).filter(
  92. config => sectionLabels[config.key]
  93. );
  94. const [_isEventErrorCollapsed, setEventErrorCollapsed] = useSyncedLocalStorageState(
  95. getFoldSectionKey(SectionKey.PROCESSING_ERROR),
  96. true
  97. );
  98. const isMobile = useMedia(`(max-width: ${theme.breakpoints.small})`);
  99. const {baseUrl} = useGroupDetailsRoute();
  100. const {data: actionableItems} = useActionableItems({
  101. eventId: event.id,
  102. orgSlug: organization.slug,
  103. projectSlug: group.project.slug,
  104. });
  105. const hasEventError = actionableItems?.errors && actionableItems.errors.length > 0;
  106. const selectedOption = useMemo(() => {
  107. if (query?.trim()) {
  108. return EventNavOptions.CUSTOM;
  109. }
  110. switch (params.eventId) {
  111. case EventNavOptions.RECOMMENDED:
  112. case EventNavOptions.LATEST:
  113. case EventNavOptions.OLDEST:
  114. return params.eventId;
  115. case undefined:
  116. return defaultIssueEvent;
  117. default:
  118. return EventNavOptions.CUSTOM;
  119. }
  120. }, [query, params.eventId, defaultIssueEvent]);
  121. const hasPreviousEvent = defined(event.previousEventID);
  122. const hasNextEvent = defined(event.nextEventID);
  123. const baseEventsPath = `/organizations/${organization.slug}/issues/${group.id}/events/`;
  124. const grayText = css`
  125. color: ${theme.subText};
  126. font-weight: ${theme.fontWeightNormal};
  127. `;
  128. const downloadJson = () => {
  129. const host = organization.links.regionUrl;
  130. const jsonUrl = `${host}/api/0/projects/${organization.slug}/${group.project.slug}/events/${event.id}/json/`;
  131. window.open(jsonUrl);
  132. trackAnalytics('issue_details.event_json_clicked', {
  133. organization,
  134. group_id: parseInt(`${event.groupID}`, 10),
  135. });
  136. };
  137. const {onClick: copyLink} = useCopyToClipboard({
  138. successMessage: t('Event URL copied to clipboard'),
  139. text: window.location.origin + normalizeUrl(`${baseEventsPath}${event.id}/`),
  140. onCopy: () =>
  141. trackAnalytics('issue_details.copy_event_link_clicked', {
  142. organization,
  143. ...getAnalyticsDataForGroup(group),
  144. ...getAnalyticsDataForEvent(event),
  145. }),
  146. });
  147. const {onClick: copyEventId} = useCopyToClipboard({
  148. successMessage: t('Event ID copied to clipboard'),
  149. text: event.id,
  150. });
  151. return (
  152. <div {...props} ref={ref}>
  153. <EventNavigationWrapper>
  154. <Tabs value={selectedOption}>
  155. <TabList hideBorder variant="floating">
  156. {EventNavOrder.map(label => {
  157. const eventPath =
  158. label === selectedOption
  159. ? undefined
  160. : {
  161. pathname: normalizeUrl(baseEventsPath + label + '/'),
  162. query: {...location.query, referrer: `${label}-event`},
  163. };
  164. return (
  165. <TabList.Item
  166. to={eventPath}
  167. key={label}
  168. hidden={
  169. label === EventNavOptions.CUSTOM &&
  170. selectedOption !== EventNavOptions.CUSTOM
  171. }
  172. textValue={`${EventNavLabels[label]} Event`}
  173. >
  174. {EventNavLabels[label]} {isMobile ? '' : t('Event')}
  175. </TabList.Item>
  176. );
  177. })}
  178. </TabList>
  179. </Tabs>
  180. <NavigationWrapper>
  181. <Navigation>
  182. <Tooltip title={t('Previous Event')} skipWrapper>
  183. <LinkButton
  184. aria-label={t('Previous Event')}
  185. borderless
  186. size="xs"
  187. icon={<IconChevron direction="left" />}
  188. disabled={!hasPreviousEvent}
  189. to={{
  190. pathname: `${baseEventsPath}${event.previousEventID}/`,
  191. query: {...location.query, referrer: 'previous-event'},
  192. }}
  193. css={grayText}
  194. />
  195. </Tooltip>
  196. <Tooltip title={t('Next Event')} skipWrapper>
  197. <LinkButton
  198. aria-label={t('Next Event')}
  199. borderless
  200. size="xs"
  201. icon={<IconChevron direction="right" />}
  202. disabled={!hasNextEvent}
  203. to={{
  204. pathname: `${baseEventsPath}${event.nextEventID}/`,
  205. query: {...location.query, referrer: 'next-event'},
  206. }}
  207. css={grayText}
  208. />
  209. </Tooltip>
  210. </Navigation>
  211. <LinkButton
  212. to={{
  213. pathname: `${baseUrl}${TabPaths[Tab.EVENTS]}`,
  214. query: location.query,
  215. }}
  216. borderless
  217. size="xs"
  218. css={grayText}
  219. >
  220. {isMobile ? '' : t('View')} {t('All Events')}
  221. </LinkButton>
  222. </NavigationWrapper>
  223. </EventNavigationWrapper>
  224. <EventInfoJumpToWrapper>
  225. <EventInfo>
  226. <EventIdInfo>
  227. <EventTitle>{t('Event')}</EventTitle>
  228. <DropdownMenu
  229. trigger={(triggerProps, isOpen) => (
  230. // Tooltip split from button to prevent re-opening w/ focus event on close
  231. <Tooltip
  232. title={event.id}
  233. delay={500}
  234. overlayStyle={{maxWidth: 'max-content'}}
  235. disabled={isOpen}
  236. >
  237. <DropdownButton
  238. {...triggerProps}
  239. aria-label={t('Event actions')}
  240. size="zero"
  241. borderless
  242. isOpen={isOpen}
  243. >
  244. {getShortEventId(event.id)}
  245. </DropdownButton>
  246. </Tooltip>
  247. )}
  248. position="bottom"
  249. size="xs"
  250. items={[
  251. {
  252. key: 'copy-event-id',
  253. label: t('Copy Event ID'),
  254. onAction: copyEventId,
  255. },
  256. {
  257. key: 'copy-event-link',
  258. label: t('Copy Event Link'),
  259. onAction: copyLink,
  260. },
  261. {
  262. key: 'view-json',
  263. label: t('View JSON'),
  264. onAction: downloadJson,
  265. },
  266. ]}
  267. />
  268. </EventIdInfo>
  269. <StyledTimeSince
  270. date={event.dateCreated ?? event.dateReceived}
  271. css={grayText}
  272. />
  273. {hasEventError && (
  274. <Fragment>
  275. <Divider />
  276. <ProcessingErrorButton
  277. title={t(
  278. 'Sentry has detected configuration issues with this event. Click for more info.'
  279. )}
  280. borderless
  281. size="zero"
  282. icon={<IconWarning color="red300" />}
  283. onClick={() => {
  284. document
  285. .getElementById(SectionKey.PROCESSING_ERROR)
  286. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  287. setEventErrorCollapsed(false);
  288. }}
  289. >
  290. {t('Processing Error')}
  291. </ProcessingErrorButton>
  292. </Fragment>
  293. )}
  294. </EventInfo>
  295. {eventSectionConfigs.length > 0 && (
  296. <JumpTo>
  297. <div>{t('Jump to:')}</div>
  298. <ScrollCarousel gap={0.25}>
  299. {eventSectionConfigs.map(config => (
  300. <EventNavigationLink
  301. key={config.key}
  302. config={config}
  303. propCss={grayText}
  304. />
  305. ))}
  306. </ScrollCarousel>
  307. </JumpTo>
  308. )}
  309. </EventInfoJumpToWrapper>
  310. </div>
  311. );
  312. }
  313. );
  314. function EventNavigationLink({
  315. config,
  316. propCss,
  317. }: {
  318. config: SectionConfig;
  319. propCss: SerializedStyles;
  320. }) {
  321. const [_isCollapsed, setIsCollapsed] = useSyncedLocalStorageState(
  322. getFoldSectionKey(config.key),
  323. config?.initialCollapse ?? false
  324. );
  325. return (
  326. <LinkButton
  327. to={{
  328. ...location,
  329. hash: `#${config.key}`,
  330. }}
  331. onClick={event => {
  332. event.preventDefault();
  333. setIsCollapsed(false);
  334. document
  335. .getElementById(config.key)
  336. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  337. }}
  338. borderless
  339. size="xs"
  340. css={propCss}
  341. >
  342. {sectionLabels[config.key]}
  343. </LinkButton>
  344. );
  345. }
  346. const EventNavigationWrapper = styled('div')`
  347. display: flex;
  348. justify-content: space-between;
  349. align-items: center;
  350. font-size: ${p => p.theme.fontSizeSmall};
  351. padding: ${space(1)} ${space(1)};
  352. min-height: ${MIN_NAV_HEIGHT}px;
  353. border-bottom: 1px solid ${p => p.theme.border};
  354. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  355. padding: ${space(1)} ${space(1.5)};
  356. }
  357. `;
  358. const NavigationWrapper = styled('div')`
  359. display: flex;
  360. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  361. gap: ${space(0.25)};
  362. }
  363. `;
  364. const Navigation = styled('div')`
  365. display: flex;
  366. padding-right: ${space(0.25)};
  367. border-right: 1px solid ${p => p.theme.gray100};
  368. `;
  369. const StyledTimeSince = styled(TimeSince)`
  370. color: ${p => p.theme.subText};
  371. font-weight: ${p => p.theme.fontWeightNormal};
  372. white-space: nowrap;
  373. `;
  374. const EventInfoJumpToWrapper = styled('div')`
  375. display: flex;
  376. gap: ${space(1)};
  377. flex-direction: row;
  378. justify-content: space-between;
  379. align-items: center;
  380. padding: ${space(1)} ${space(2)};
  381. flex-wrap: wrap;
  382. min-height: ${MIN_NAV_HEIGHT}px;
  383. @media (min-width: ${p => p.theme.breakpoints.small}) {
  384. flex-wrap: nowrap;
  385. }
  386. box-shadow: ${p => p.theme.translucentBorder} 0 1px;
  387. `;
  388. const EventInfo = styled('div')`
  389. display: flex;
  390. gap: ${space(1)};
  391. flex-direction: row;
  392. align-items: center;
  393. line-height: 1.2;
  394. `;
  395. const JumpTo = styled('div')`
  396. display: flex;
  397. gap: ${space(1)};
  398. flex-direction: row;
  399. align-items: center;
  400. color: ${p => p.theme.subText};
  401. font-size: ${p => p.theme.fontSizeSmall};
  402. white-space: nowrap;
  403. max-width: 100%;
  404. @media (min-width: ${p => p.theme.breakpoints.small}) {
  405. max-width: 50%;
  406. }
  407. `;
  408. const EventIdInfo = styled('span')`
  409. display: flex;
  410. align-items: center;
  411. gap: ${space(0.25)};
  412. line-height: 1.2;
  413. `;
  414. const EventTitle = styled('div')`
  415. font-weight: ${p => p.theme.fontWeightBold};
  416. `;
  417. const ProcessingErrorButton = styled(Button)`
  418. color: ${p => p.theme.red300};
  419. font-weight: ${p => p.theme.fontWeightNormal};
  420. font-size: ${p => p.theme.fontSizeSmall};
  421. :hover {
  422. color: ${p => p.theme.red300};
  423. }
  424. `;