eventNavigation.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import styled from '@emotion/styled';
  2. import omit from 'lodash/omit';
  3. import {Button, LinkButton} from 'sentry/components/button';
  4. import ButtonBar from 'sentry/components/buttonBar';
  5. import {Chevron} from 'sentry/components/chevron';
  6. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  7. import {TabList, Tabs} from 'sentry/components/tabs';
  8. import TimeSince from 'sentry/components/timeSince';
  9. import {IconChevron, IconCopy} from 'sentry/icons';
  10. import {t} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {Event} from 'sentry/types/event';
  13. import type {Group} from 'sentry/types/group';
  14. import {defined} from 'sentry/utils';
  15. import {trackAnalytics} from 'sentry/utils/analytics';
  16. import {
  17. getAnalyticsDataForEvent,
  18. getAnalyticsDataForGroup,
  19. getShortEventId,
  20. } from 'sentry/utils/events';
  21. import {getReplayIdFromEvent} from 'sentry/utils/replays/getReplayIdFromEvent';
  22. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  23. import {useLocation} from 'sentry/utils/useLocation';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  26. type EventNavigationProps = {
  27. event: Event;
  28. group: Group;
  29. };
  30. type SectionDefinition = {
  31. condition: (event: Event) => boolean;
  32. label: string;
  33. section: string;
  34. };
  35. enum EventNavOptions {
  36. RECOMMENDED = 'recommended',
  37. LATEST = 'latest',
  38. OLDEST = 'oldest',
  39. }
  40. const EventNavLabels = {
  41. [EventNavOptions.RECOMMENDED]: t('Recommended Event'),
  42. [EventNavOptions.OLDEST]: t('First Event'),
  43. [EventNavOptions.LATEST]: t('Last Event'),
  44. };
  45. const eventDataSections: SectionDefinition[] = [
  46. {section: 'event-highlights', label: t('Event Highlights'), condition: () => true},
  47. {
  48. section: 'stacktrace',
  49. label: t('Stack Trace'),
  50. condition: (event: Event) => event.entries.some(entry => entry.type === 'stacktrace'),
  51. },
  52. {
  53. section: 'exception',
  54. label: t('Exception'),
  55. condition: (event: Event) => event.entries.some(entry => entry.type === 'exception'),
  56. },
  57. {
  58. section: 'breadcrumbs',
  59. label: t('Breadcrumbs'),
  60. condition: (event: Event) =>
  61. event.entries.some(entry => entry.type === 'breadcrumbs'),
  62. },
  63. {section: 'tags', label: t('Tags'), condition: (event: Event) => event.tags.length > 0},
  64. {section: 'context', label: t('Context'), condition: (event: Event) => !!event.context},
  65. {
  66. section: 'user-feedback',
  67. label: t('User Feedback'),
  68. condition: (event: Event) => !!event.userReport,
  69. },
  70. {
  71. section: 'replay',
  72. label: t('Replay'),
  73. condition: (event: Event) => !!getReplayIdFromEvent(event),
  74. },
  75. ];
  76. export default function EventNavigation({event, group}: EventNavigationProps) {
  77. const location = useLocation();
  78. const organization = useOrganization();
  79. const hasPreviousEvent = defined(event.previousEventID);
  80. const hasNextEvent = defined(event.nextEventID);
  81. const baseEventsPath = `/organizations/${organization.slug}/issues/${group.id}/events/`;
  82. const jumpToSections = eventDataSections.filter(eventSection =>
  83. eventSection.condition(event)
  84. );
  85. const downloadJson = () => {
  86. const host = organization.links.regionUrl;
  87. const jsonUrl = `${host}/api/0/projects/${organization.slug}/${group.project.slug}/events/${event.id}/json/`;
  88. window.open(jsonUrl);
  89. trackAnalytics('issue_details.event_json_clicked', {
  90. organization,
  91. group_id: parseInt(`${event.groupID}`, 10),
  92. });
  93. };
  94. const {onClick: copyLink} = useCopyToClipboard({
  95. successMessage: t('Event URL copied to clipboard'),
  96. text: window.location.origin + normalizeUrl(`${baseEventsPath}${event.id}/`),
  97. onCopy: () =>
  98. trackAnalytics('issue_details.copy_event_link_clicked', {
  99. organization,
  100. ...getAnalyticsDataForGroup(group),
  101. ...getAnalyticsDataForEvent(event),
  102. }),
  103. });
  104. const {onClick: copyEventId} = useCopyToClipboard({
  105. successMessage: t('Event ID copied to clipboard'),
  106. text: event.id,
  107. });
  108. return (
  109. <div>
  110. <EventNavigationWrapper>
  111. <Tabs>
  112. <TabList hideBorder variant="floating">
  113. {Object.keys(EventNavLabels).map(label => {
  114. return (
  115. <TabList.Item
  116. to={{
  117. pathname: normalizeUrl(baseEventsPath + label + '/'),
  118. query: {...location.query, referrer: `${label}-event`},
  119. }}
  120. key={label}
  121. >
  122. {EventNavLabels[label]}
  123. </TabList.Item>
  124. );
  125. })}
  126. </TabList>
  127. </Tabs>
  128. <NavigationWrapper>
  129. <Navigation>
  130. <LinkButton
  131. title={'Previous Event'}
  132. aria-label="Previous Event"
  133. borderless
  134. size="sm"
  135. icon={<IconChevron direction="left" />}
  136. disabled={!hasPreviousEvent}
  137. to={{
  138. pathname: `${baseEventsPath}${event.previousEventID}/`,
  139. query: {...location.query, referrer: 'previous-event'},
  140. }}
  141. />
  142. <LinkButton
  143. title={'Next Event'}
  144. aria-label="Next Event"
  145. borderless
  146. size="sm"
  147. icon={<IconChevron direction="right" />}
  148. disabled={!hasNextEvent}
  149. to={{
  150. pathname: `${baseEventsPath}${event.nextEventID}/`,
  151. query: {...location.query, referrer: 'next-event'},
  152. }}
  153. />
  154. </Navigation>
  155. <LinkButton
  156. to={{
  157. pathname: normalizeUrl(
  158. `/organizations/${organization.slug}/issues/${group.id}/events/`
  159. ),
  160. query: omit(location.query, 'query'),
  161. }}
  162. borderless
  163. size="sm"
  164. >
  165. {t('View All Events')}
  166. </LinkButton>
  167. </NavigationWrapper>
  168. </EventNavigationWrapper>
  169. <Divider />
  170. <EventInfoJumpToWrapper>
  171. <EventInfo>
  172. <EventIdInfo>
  173. <EventTitle>{t('Event')}</EventTitle>
  174. <Button
  175. aria-label={t('Copy')}
  176. borderless
  177. onClick={copyEventId}
  178. size="zero"
  179. title={event.id}
  180. tooltipProps={{overlayStyle: {maxWidth: 'max-content'}}}
  181. translucentBorder
  182. >
  183. <EventId>
  184. {getShortEventId(event.id)}
  185. <CopyIconContainer>
  186. <IconCopy size="xs" />
  187. </CopyIconContainer>
  188. </EventId>
  189. </Button>
  190. <DropdownMenu
  191. triggerProps={{
  192. 'aria-label': t('Event actions'),
  193. icon: <Chevron direction="down" />,
  194. size: 'zero',
  195. borderless: true,
  196. showChevron: false,
  197. }}
  198. position="bottom"
  199. size="xs"
  200. items={[
  201. {
  202. key: 'copy-event-id',
  203. label: t('Copy Event ID'),
  204. onAction: copyEventId,
  205. },
  206. {
  207. key: 'copy-event-link',
  208. label: t('Copy Event Link'),
  209. onAction: copyLink,
  210. },
  211. {
  212. key: 'view-json',
  213. label: t('View JSON'),
  214. onAction: downloadJson,
  215. },
  216. ]}
  217. />
  218. </EventIdInfo>
  219. <TimeSince date={event.dateCreated ?? event.dateReceived} />
  220. </EventInfo>
  221. <JumpTo>
  222. <div>{t('Jump to:')}</div>
  223. <ButtonBar>
  224. {jumpToSections.map(jump => (
  225. <StyledButton
  226. key={jump.section}
  227. onClick={() => {
  228. document
  229. .getElementById(jump.section)
  230. ?.scrollIntoView({behavior: 'smooth'});
  231. }}
  232. borderless
  233. size="sm"
  234. >
  235. {jump.label}
  236. </StyledButton>
  237. ))}
  238. </ButtonBar>
  239. </JumpTo>
  240. </EventInfoJumpToWrapper>
  241. </div>
  242. );
  243. }
  244. const EventNavigationWrapper = styled('div')`
  245. display: flex;
  246. justify-content: space-between;
  247. `;
  248. const NavigationWrapper = styled('div')`
  249. display: flex;
  250. `;
  251. const Navigation = styled('div')`
  252. display: flex;
  253. border-right: 1px solid ${p => p.theme.gray100};
  254. `;
  255. const EventInfoJumpToWrapper = styled('div')`
  256. display: flex;
  257. gap: ${space(1)};
  258. flex-direction: row;
  259. justify-content: space-between;
  260. align-items: center;
  261. `;
  262. const EventInfo = styled('div')`
  263. display: flex;
  264. gap: ${space(1)};
  265. flex-direction: row;
  266. align-items: center;
  267. `;
  268. const JumpTo = styled('div')`
  269. display: flex;
  270. gap: ${space(1)};
  271. flex-direction: row;
  272. align-items: center;
  273. color: ${p => p.theme.gray300};
  274. `;
  275. const Divider = styled('hr')`
  276. height: 1px;
  277. width: 100%;
  278. background: ${p => p.theme.border};
  279. border: none;
  280. margin-top: ${space(1)};
  281. margin-bottom: ${space(1)};
  282. `;
  283. const EventIdInfo = styled('span')`
  284. display: flex;
  285. align-items: center;
  286. gap: ${space(0.25)};
  287. `;
  288. const EventId = styled('span')`
  289. position: relative;
  290. font-weight: ${p => p.theme.fontWeightBold};
  291. font-size: ${p => p.theme.fontSizeLarge};
  292. text-decoration: underline;
  293. text-decoration-color: ${p => p.theme.gray200};
  294. &:hover {
  295. > span {
  296. display: flex;
  297. }
  298. }
  299. `;
  300. const StyledButton = styled(Button)`
  301. color: ${p => p.theme.gray300};
  302. `;
  303. const CopyIconContainer = styled('span')`
  304. display: none;
  305. align-items: center;
  306. padding: ${space(0.25)};
  307. background: ${p => p.theme.background};
  308. position: absolute;
  309. right: 0;
  310. top: 50%;
  311. transform: translateY(-50%);
  312. `;
  313. const EventTitle = styled('div')`
  314. font-weight: ${p => p.theme.fontWeightBold};
  315. font-size: ${p => p.theme.fontSizeLarge};
  316. `;