eventTitle.tsx 9.0 KB

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