eventTitle.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import {type CSSProperties, forwardRef, Fragment} 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 ExternalLink from 'sentry/components/links/externalLink';
  9. import {ScrollCarousel} from 'sentry/components/scrollCarousel';
  10. import TimeSince from 'sentry/components/timeSince';
  11. import {IconWarning} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {Event} from 'sentry/types/event';
  15. import type {Group} from 'sentry/types/group';
  16. import {trackAnalytics} from 'sentry/utils/analytics';
  17. import {
  18. getAnalyticsDataForEvent,
  19. getAnalyticsDataForGroup,
  20. getShortEventId,
  21. } from 'sentry/utils/events';
  22. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  23. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
  26. import {Divider} from 'sentry/views/issueDetails/divider';
  27. import EventCreatedTooltip from 'sentry/views/issueDetails/eventCreatedTooltip';
  28. import {
  29. type SectionConfig,
  30. SectionKey,
  31. useIssueDetails,
  32. } from 'sentry/views/issueDetails/streamline/context';
  33. import {getFoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection';
  34. type EventNavigationProps = {
  35. event: Event;
  36. group: Group;
  37. className?: string;
  38. /**
  39. * Data property to help style the component when it's sticky
  40. */
  41. 'data-stuck'?: boolean;
  42. style?: CSSProperties;
  43. };
  44. const sectionLabels: Partial<Record<SectionKey, string>> = {
  45. [SectionKey.HIGHLIGHTS]: t('Highlights'),
  46. [SectionKey.STACKTRACE]: t('Stack Trace'),
  47. [SectionKey.EXCEPTION]: t('Stack Trace'),
  48. [SectionKey.THREADS]: t('Stack Trace'),
  49. [SectionKey.REPLAY]: t('Replay'),
  50. [SectionKey.BREADCRUMBS]: t('Breadcrumbs'),
  51. [SectionKey.TRACE]: t('Trace'),
  52. [SectionKey.TAGS]: t('Tags'),
  53. [SectionKey.CONTEXTS]: t('Context'),
  54. [SectionKey.USER_FEEDBACK]: t('User Feedback'),
  55. [SectionKey.FEATURE_FLAGS]: t('Flags'),
  56. };
  57. export const MIN_NAV_HEIGHT = 44;
  58. export const EventTitle = forwardRef<HTMLDivElement, EventNavigationProps>(
  59. function EventNavigation({event, group, ...props}, ref) {
  60. const organization = useOrganization();
  61. const theme = useTheme();
  62. const {sectionData} = useIssueDetails();
  63. const eventSectionConfigs = Object.values(sectionData ?? {}).filter(
  64. config => sectionLabels[config.key]
  65. );
  66. const [_isEventErrorCollapsed, setEventErrorCollapsed] = useSyncedLocalStorageState(
  67. getFoldSectionKey(SectionKey.PROCESSING_ERROR),
  68. true
  69. );
  70. const {data: actionableItems} = useActionableItems({
  71. eventId: event.id,
  72. orgSlug: organization.slug,
  73. projectSlug: group.project.slug,
  74. });
  75. const hasEventError = actionableItems?.errors && actionableItems.errors.length > 0;
  76. const baseEventsPath = `/organizations/${organization.slug}/issues/${group.id}/events/`;
  77. const grayText = css`
  78. color: ${theme.subText};
  79. font-weight: ${theme.fontWeightNormal};
  80. `;
  81. const host = organization.links.regionUrl;
  82. const jsonUrl = `${host}/api/0/projects/${organization.slug}/${group.project.slug}/events/${event.id}/json/`;
  83. const downloadJson = () => {
  84. window.open(jsonUrl);
  85. trackAnalytics('issue_details.event_json_clicked', {
  86. organization,
  87. group_id: parseInt(`${event.groupID}`, 10),
  88. streamline: true,
  89. });
  90. };
  91. const {onClick: copyLink} = useCopyToClipboard({
  92. successMessage: t('Event URL copied to clipboard'),
  93. text: window.location.origin + normalizeUrl(`${baseEventsPath}${event.id}/`),
  94. onCopy: () =>
  95. trackAnalytics('issue_details.copy_event_link_clicked', {
  96. organization,
  97. ...getAnalyticsDataForGroup(group),
  98. ...getAnalyticsDataForEvent(event),
  99. streamline: true,
  100. }),
  101. });
  102. const {onClick: copyEventId} = useCopyToClipboard({
  103. successMessage: t('Event ID copied to clipboard'),
  104. text: event.id,
  105. onCopy: () =>
  106. trackAnalytics('issue_details.copy_event_id_clicked', {
  107. organization,
  108. ...getAnalyticsDataForGroup(group),
  109. ...getAnalyticsDataForEvent(event),
  110. streamline: true,
  111. }),
  112. });
  113. return (
  114. <div {...props} ref={ref}>
  115. <EventInfoJumpToWrapper>
  116. <EventInfo>
  117. <DropdownMenu
  118. trigger={(triggerProps, isOpen) => (
  119. <EventIdDropdownButton
  120. {...triggerProps}
  121. aria-label={t('Event actions')}
  122. size="sm"
  123. borderless
  124. isOpen={isOpen}
  125. >
  126. {getShortEventId(event.id)}
  127. </EventIdDropdownButton>
  128. )}
  129. position="bottom-start"
  130. offset={4}
  131. size="xs"
  132. items={[
  133. {
  134. key: 'copy-event-id',
  135. label: t('Copy Event ID'),
  136. onAction: copyEventId,
  137. },
  138. {
  139. key: 'copy-event-link',
  140. label: t('Copy Event Link'),
  141. onAction: copyLink,
  142. },
  143. {
  144. key: 'view-json',
  145. label: t('View JSON'),
  146. onAction: downloadJson,
  147. className: 'hidden-sm hidden-md hidden-lg',
  148. },
  149. ]}
  150. />
  151. <StyledTimeSince
  152. tooltipBody={<EventCreatedTooltip event={event} />}
  153. tooltipProps={{maxWidth: 300}}
  154. date={event.dateCreated ?? event.dateReceived}
  155. css={grayText}
  156. aria-label={t('Event timestamp')}
  157. />
  158. <JsonLinkWrapper className="hidden-xs">
  159. <Divider />
  160. <JsonLink
  161. href={jsonUrl}
  162. onClick={() =>
  163. trackAnalytics('issue_details.event_json_clicked', {
  164. organization,
  165. group_id: parseInt(`${event.groupID}`, 10),
  166. streamline: true,
  167. })
  168. }
  169. >
  170. {t('JSON')}
  171. </JsonLink>
  172. </JsonLinkWrapper>
  173. {hasEventError && (
  174. <Fragment>
  175. <Divider />
  176. <ProcessingErrorButton
  177. title={t(
  178. 'Sentry has detected configuration issues with this event. Click for more info.'
  179. )}
  180. borderless
  181. size="zero"
  182. icon={<IconWarning color="red300" />}
  183. onClick={() => {
  184. document
  185. .getElementById(SectionKey.PROCESSING_ERROR)
  186. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  187. setEventErrorCollapsed(false);
  188. }}
  189. >
  190. {t('Processing Error')}
  191. </ProcessingErrorButton>
  192. </Fragment>
  193. )}
  194. </EventInfo>
  195. {eventSectionConfigs.length > 0 && (
  196. <JumpTo>
  197. <div aria-hidden>{t('Jump to:')}</div>
  198. <ScrollCarousel gap={0.25} aria-label={t('Jump to section links')}>
  199. {eventSectionConfigs.map(config => (
  200. <EventNavigationLink
  201. key={config.key}
  202. config={config}
  203. propCss={grayText}
  204. />
  205. ))}
  206. </ScrollCarousel>
  207. </JumpTo>
  208. )}
  209. </EventInfoJumpToWrapper>
  210. </div>
  211. );
  212. }
  213. );
  214. function EventNavigationLink({
  215. config,
  216. propCss,
  217. }: {
  218. config: SectionConfig;
  219. propCss: SerializedStyles;
  220. }) {
  221. const [_isCollapsed, setIsCollapsed] = useSyncedLocalStorageState(
  222. getFoldSectionKey(config.key),
  223. config?.initialCollapse ?? false
  224. );
  225. return (
  226. <LinkButton
  227. to={{
  228. ...location,
  229. hash: `#${config.key}`,
  230. }}
  231. onClick={event => {
  232. event.preventDefault();
  233. setIsCollapsed(false);
  234. document
  235. .getElementById(config.key)
  236. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  237. }}
  238. borderless
  239. size="xs"
  240. css={propCss}
  241. analyticsEventName="Issue Details: Jump To Clicked"
  242. analyticsEventKey="issue_details.jump_to_clicked"
  243. analyticsParams={{section: config.key}}
  244. >
  245. {sectionLabels[config.key]}
  246. </LinkButton>
  247. );
  248. }
  249. const StyledTimeSince = styled(TimeSince)`
  250. color: ${p => p.theme.subText};
  251. font-weight: ${p => p.theme.fontWeightNormal};
  252. white-space: nowrap;
  253. `;
  254. const EventInfoJumpToWrapper = styled('div')`
  255. display: flex;
  256. gap: ${space(1)};
  257. flex-direction: row;
  258. justify-content: space-between;
  259. align-items: center;
  260. padding: 0 ${space(2)} 0 ${space(0.5)};
  261. flex-wrap: wrap;
  262. min-height: ${MIN_NAV_HEIGHT}px;
  263. @media (min-width: ${p => p.theme.breakpoints.small}) {
  264. flex-wrap: nowrap;
  265. }
  266. border-bottom: 1px solid ${p => p.theme.translucentBorder};
  267. `;
  268. const EventIdDropdownButton = styled(DropdownButton)`
  269. padding-right: ${space(0.5)};
  270. `;
  271. const EventInfo = styled('div')`
  272. display: flex;
  273. gap: ${space(0.5)};
  274. flex-direction: row;
  275. align-items: center;
  276. line-height: 1.2;
  277. `;
  278. const JumpTo = styled('div')`
  279. display: flex;
  280. gap: ${space(1)};
  281. flex-direction: row;
  282. align-items: center;
  283. color: ${p => p.theme.subText};
  284. font-size: ${p => p.theme.fontSizeSmall};
  285. white-space: nowrap;
  286. max-width: 100%;
  287. @media (min-width: ${p => p.theme.breakpoints.small}) {
  288. max-width: 50%;
  289. }
  290. `;
  291. const ProcessingErrorButton = styled(Button)`
  292. color: ${p => p.theme.red300};
  293. font-weight: ${p => p.theme.fontWeightNormal};
  294. font-size: ${p => p.theme.fontSizeSmall};
  295. :hover {
  296. color: ${p => p.theme.red300};
  297. }
  298. `;
  299. const JsonLinkWrapper = styled('div')`
  300. display: flex;
  301. gap: ${space(0.5)};
  302. `;
  303. const JsonLink = styled(ExternalLink)`
  304. color: ${p => p.theme.gray300};
  305. text-decoration: underline;
  306. text-decoration-color: ${p => p.theme.translucentGray200};
  307. :hover {
  308. color: ${p => p.theme.gray300};
  309. }
  310. `;