eventNavigation.tsx 14 KB

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