eventTitle.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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 {ScrollCarousel} from 'sentry/components/scrollCarousel';
  9. import TimeSince from 'sentry/components/timeSince';
  10. import {Tooltip} from 'sentry/components/tooltip';
  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. useEventDetails,
  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 = {
  45. [SectionKey.HIGHLIGHTS]: t('Highlights'),
  46. [SectionKey.STACKTRACE]: t('Stack Trace'),
  47. [SectionKey.TRACE]: t('Trace'),
  48. [SectionKey.EXCEPTION]: t('Stack Trace'),
  49. [SectionKey.BREADCRUMBS]: t('Breadcrumbs'),
  50. [SectionKey.TAGS]: t('Tags'),
  51. [SectionKey.CONTEXTS]: t('Context'),
  52. [SectionKey.USER_FEEDBACK]: t('User Feedback'),
  53. [SectionKey.REPLAY]: t('Replay'),
  54. [SectionKey.FEATURE_FLAGS]: t('Flags'),
  55. };
  56. export const MIN_NAV_HEIGHT = 44;
  57. export const EventTitle = forwardRef<HTMLDivElement, EventNavigationProps>(
  58. function EventNavigation({event, group, ...props}, ref) {
  59. const organization = useOrganization();
  60. const theme = useTheme();
  61. const {sectionData} = useEventDetails();
  62. const eventSectionConfigs = Object.values(sectionData ?? {}).filter(
  63. config => sectionLabels[config.key]
  64. );
  65. const [_isEventErrorCollapsed, setEventErrorCollapsed] = useSyncedLocalStorageState(
  66. getFoldSectionKey(SectionKey.PROCESSING_ERROR),
  67. true
  68. );
  69. const {data: actionableItems} = useActionableItems({
  70. eventId: event.id,
  71. orgSlug: organization.slug,
  72. projectSlug: group.project.slug,
  73. });
  74. const hasEventError = actionableItems?.errors && actionableItems.errors.length > 0;
  75. const baseEventsPath = `/organizations/${organization.slug}/issues/${group.id}/events/`;
  76. const grayText = css`
  77. color: ${theme.subText};
  78. font-weight: ${theme.fontWeightNormal};
  79. `;
  80. const downloadJson = () => {
  81. const host = organization.links.regionUrl;
  82. const jsonUrl = `${host}/api/0/projects/${organization.slug}/${group.project.slug}/events/${event.id}/json/`;
  83. window.open(jsonUrl);
  84. trackAnalytics('issue_details.event_json_clicked', {
  85. organization,
  86. group_id: parseInt(`${event.groupID}`, 10),
  87. });
  88. };
  89. const {onClick: copyLink} = useCopyToClipboard({
  90. successMessage: t('Event URL copied to clipboard'),
  91. text: window.location.origin + normalizeUrl(`${baseEventsPath}${event.id}/`),
  92. onCopy: () =>
  93. trackAnalytics('issue_details.copy_event_link_clicked', {
  94. organization,
  95. ...getAnalyticsDataForGroup(group),
  96. ...getAnalyticsDataForEvent(event),
  97. }),
  98. });
  99. const {onClick: copyEventId} = useCopyToClipboard({
  100. successMessage: t('Event ID copied to clipboard'),
  101. text: event.id,
  102. });
  103. return (
  104. <div {...props} ref={ref}>
  105. <EventInfoJumpToWrapper>
  106. <EventInfo>
  107. <EventIdInfo>
  108. <DropdownMenu
  109. trigger={(triggerProps, isOpen) => (
  110. // Tooltip split from button to prevent re-opening w/ focus event on close
  111. <Tooltip
  112. title={event.id}
  113. delay={500}
  114. overlayStyle={{maxWidth: 'max-content'}}
  115. disabled={isOpen}
  116. >
  117. <DropdownButton
  118. {...triggerProps}
  119. aria-label={t('Event actions')}
  120. size="zero"
  121. borderless
  122. isOpen={isOpen}
  123. >
  124. {getShortEventId(event.id)}
  125. </DropdownButton>
  126. </Tooltip>
  127. )}
  128. position="bottom"
  129. size="xs"
  130. items={[
  131. {
  132. key: 'copy-event-id',
  133. label: t('Copy Event ID'),
  134. onAction: copyEventId,
  135. },
  136. {
  137. key: 'copy-event-link',
  138. label: t('Copy Event Link'),
  139. onAction: copyLink,
  140. },
  141. {
  142. key: 'view-json',
  143. label: t('View JSON'),
  144. onAction: downloadJson,
  145. },
  146. ]}
  147. />
  148. </EventIdInfo>
  149. <StyledTimeSince
  150. tooltipBody={<EventCreatedTooltip event={event} />}
  151. tooltipProps={{overlayStyle: {maxWidth: 300}}}
  152. date={event.dateCreated ?? event.dateReceived}
  153. css={grayText}
  154. aria-label={t('Event timestamp')}
  155. />
  156. {hasEventError && (
  157. <Fragment>
  158. <Divider />
  159. <ProcessingErrorButton
  160. title={t(
  161. 'Sentry has detected configuration issues with this event. Click for more info.'
  162. )}
  163. borderless
  164. size="zero"
  165. icon={<IconWarning color="red300" />}
  166. onClick={() => {
  167. document
  168. .getElementById(SectionKey.PROCESSING_ERROR)
  169. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  170. setEventErrorCollapsed(false);
  171. }}
  172. >
  173. {t('Processing Error')}
  174. </ProcessingErrorButton>
  175. </Fragment>
  176. )}
  177. </EventInfo>
  178. {eventSectionConfigs.length > 0 && (
  179. <JumpTo>
  180. <div aria-hidden>{t('Jump to:')}</div>
  181. <ScrollCarousel gap={0.25} aria-label={t('Jump to section links')}>
  182. {eventSectionConfigs.map(config => (
  183. <EventNavigationLink
  184. key={config.key}
  185. config={config}
  186. propCss={grayText}
  187. />
  188. ))}
  189. </ScrollCarousel>
  190. </JumpTo>
  191. )}
  192. </EventInfoJumpToWrapper>
  193. </div>
  194. );
  195. }
  196. );
  197. function EventNavigationLink({
  198. config,
  199. propCss,
  200. }: {
  201. config: SectionConfig;
  202. propCss: SerializedStyles;
  203. }) {
  204. const [_isCollapsed, setIsCollapsed] = useSyncedLocalStorageState(
  205. getFoldSectionKey(config.key),
  206. config?.initialCollapse ?? false
  207. );
  208. return (
  209. <LinkButton
  210. to={{
  211. ...location,
  212. hash: `#${config.key}`,
  213. }}
  214. onClick={event => {
  215. event.preventDefault();
  216. setIsCollapsed(false);
  217. document
  218. .getElementById(config.key)
  219. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  220. }}
  221. borderless
  222. size="xs"
  223. css={propCss}
  224. analyticsEventName="Issue Details: Jump To Clicked"
  225. analyticsEventKey="issue_details.jump_to_clicked"
  226. analyticsParams={{section: config.key}}
  227. >
  228. {sectionLabels[config.key]}
  229. </LinkButton>
  230. );
  231. }
  232. const StyledTimeSince = styled(TimeSince)`
  233. color: ${p => p.theme.subText};
  234. font-weight: ${p => p.theme.fontWeightNormal};
  235. white-space: nowrap;
  236. `;
  237. const EventInfoJumpToWrapper = styled('div')`
  238. display: flex;
  239. gap: ${space(1)};
  240. flex-direction: row;
  241. justify-content: space-between;
  242. align-items: center;
  243. padding: ${space(1)} ${space(2)};
  244. flex-wrap: wrap;
  245. min-height: ${MIN_NAV_HEIGHT}px;
  246. @media (min-width: ${p => p.theme.breakpoints.small}) {
  247. flex-wrap: nowrap;
  248. }
  249. box-shadow: ${p => p.theme.translucentBorder} 0 1px;
  250. `;
  251. const EventInfo = styled('div')`
  252. display: flex;
  253. gap: ${space(1)};
  254. flex-direction: row;
  255. align-items: center;
  256. line-height: 1.2;
  257. `;
  258. const JumpTo = styled('div')`
  259. display: flex;
  260. gap: ${space(1)};
  261. flex-direction: row;
  262. align-items: center;
  263. color: ${p => p.theme.subText};
  264. font-size: ${p => p.theme.fontSizeSmall};
  265. white-space: nowrap;
  266. max-width: 100%;
  267. @media (min-width: ${p => p.theme.breakpoints.small}) {
  268. max-width: 50%;
  269. }
  270. `;
  271. const EventIdInfo = styled('span')`
  272. display: flex;
  273. align-items: center;
  274. gap: ${space(0.25)};
  275. line-height: 1.2;
  276. `;
  277. const ProcessingErrorButton = styled(Button)`
  278. color: ${p => p.theme.red300};
  279. font-weight: ${p => p.theme.fontWeightNormal};
  280. font-size: ${p => p.theme.fontSizeSmall};
  281. :hover {
  282. color: ${p => p.theme.red300};
  283. }
  284. `;