header.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import Color from 'color';
  4. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  5. import {Breadcrumbs} from 'sentry/components/breadcrumbs';
  6. import {LinkButton} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import {Flex} from 'sentry/components/container/flex';
  9. import Count from 'sentry/components/count';
  10. import ErrorLevel from 'sentry/components/events/errorLevel';
  11. import {getBadgeProperties} from 'sentry/components/group/inboxBadges/statusBadge';
  12. import UnhandledTag from 'sentry/components/group/inboxBadges/unhandledTag';
  13. import Link from 'sentry/components/links/link';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconInfo} 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 type {Project} from 'sentry/types/project';
  21. import {defined} from 'sentry/utils';
  22. import {getMessage, getTitle} from 'sentry/utils/events';
  23. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  24. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {GroupActions} from 'sentry/views/issueDetails/actions/index';
  28. import {NewIssueExperienceButton} from 'sentry/views/issueDetails/actions/newIssueExperienceButton';
  29. import {Divider} from 'sentry/views/issueDetails/divider';
  30. import GroupPriority from 'sentry/views/issueDetails/groupPriority';
  31. import {GroupHeaderAssigneeSelector} from 'sentry/views/issueDetails/streamline/header/assigneeSelector';
  32. import {AttachmentsBadge} from 'sentry/views/issueDetails/streamline/header/attachmentsBadge';
  33. import {IssueIdBreadcrumb} from 'sentry/views/issueDetails/streamline/header/issueIdBreadcrumb';
  34. import {ReplayBadge} from 'sentry/views/issueDetails/streamline/header/replayBadge';
  35. import {UserFeedbackBadge} from 'sentry/views/issueDetails/streamline/header/userFeedbackBadge';
  36. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  37. import {ReprocessingStatus} from 'sentry/views/issueDetails/utils';
  38. interface GroupHeaderProps {
  39. event: Event | null;
  40. group: Group;
  41. groupReprocessingStatus: ReprocessingStatus;
  42. project: Project;
  43. }
  44. export default function StreamlinedGroupHeader({
  45. event,
  46. group,
  47. groupReprocessingStatus,
  48. project,
  49. }: GroupHeaderProps) {
  50. const location = useLocation();
  51. const organization = useOrganization();
  52. const {baseUrl} = useGroupDetailsRoute();
  53. const {sort: _sort, ...query} = location.query;
  54. const {count: eventCount, userCount} = group;
  55. const {title: primaryTitle, subtitle} = getTitle(group);
  56. const secondaryTitle = getMessage(group);
  57. const isComplete = group.status === 'resolved' || group.status === 'ignored';
  58. const disableActions = [
  59. ReprocessingStatus.REPROCESSING,
  60. ReprocessingStatus.REPROCESSED_AND_HASNT_EVENT,
  61. ].includes(groupReprocessingStatus);
  62. const statusProps = getBadgeProperties(group.status, group.substatus);
  63. const issueTypeConfig = getConfigForIssueType(group, project);
  64. const hasOnlyOneUIOption = defined(organization.streamlineOnly);
  65. const [showLearnMore, setShowLearnMore] = useLocalStorageState(
  66. 'issue-details-learn-more',
  67. true
  68. );
  69. return (
  70. <Fragment>
  71. <Header>
  72. <Flex justify="space-between">
  73. <Flex align="center">
  74. <Breadcrumbs
  75. crumbs={[
  76. {
  77. label: 'Issues',
  78. to: {
  79. pathname: `/organizations/${organization.slug}/issues/`,
  80. query,
  81. },
  82. },
  83. {
  84. label: <IssueIdBreadcrumb project={project} group={group} />,
  85. },
  86. ]}
  87. />
  88. </Flex>
  89. <ButtonBar gap={0.5}>
  90. {!hasOnlyOneUIOption && (
  91. <LinkButton
  92. size="xs"
  93. external
  94. title={t('Learn more about the new UI')}
  95. href={`https://docs.sentry.io/product/issues/issue-details/`}
  96. aria-label={t('Learn more about the new UI')}
  97. icon={<IconInfo />}
  98. analyticsEventKey="issue_details.streamline_ui_learn_more"
  99. analyticsEventName="Issue Details: Streamline UI Learn More"
  100. analyticsParams={{show_learn_more: showLearnMore}}
  101. onClick={() => setShowLearnMore(false)}
  102. >
  103. {showLearnMore ? t("See What's New") : null}
  104. </LinkButton>
  105. )}
  106. <NewIssueExperienceButton />
  107. </ButtonBar>
  108. </Flex>
  109. <HeaderGrid>
  110. <Flex gap={space(0.75)} align="baseline">
  111. <PrimaryTitle
  112. title={primaryTitle}
  113. isHoverable
  114. showOnlyOnOverflow
  115. delay={1000}
  116. >
  117. {primaryTitle}
  118. </PrimaryTitle>
  119. <SecondaryTitle
  120. title={secondaryTitle}
  121. isHoverable
  122. showOnlyOnOverflow
  123. delay={1000}
  124. isDefault={!secondaryTitle}
  125. >
  126. {secondaryTitle ?? t('No error message')}
  127. </SecondaryTitle>
  128. </Flex>
  129. <StatTitle>
  130. {issueTypeConfig.eventAndUserCounts.enabled && (
  131. <StatLink
  132. to={`${baseUrl}events/${location.search}`}
  133. aria-label={t('View events')}
  134. >
  135. {t('Events')}
  136. </StatLink>
  137. )}
  138. </StatTitle>
  139. <StatTitle>
  140. {issueTypeConfig.eventAndUserCounts.enabled &&
  141. (userCount === 0 ? (
  142. t('Users')
  143. ) : (
  144. <StatLink
  145. to={`${baseUrl}tags/user/${location.search}`}
  146. aria-label={t('View affected users')}
  147. >
  148. {t('Users')}
  149. </StatLink>
  150. ))}
  151. </StatTitle>
  152. <Flex gap={space(1)} align="center" justify="flex-start">
  153. <Fragment>
  154. {issueTypeConfig.logLevel.enabled && (
  155. <ErrorLevel level={group.level} size={10} />
  156. )}
  157. {group.isUnhandled && <UnhandledTag />}
  158. {(issueTypeConfig.logLevel.enabled || group.isUnhandled) && <Divider />}
  159. </Fragment>
  160. {statusProps?.status ? (
  161. <Fragment>
  162. <Tooltip title={statusProps?.tooltip}>
  163. <Subtext>{statusProps?.status}</Subtext>
  164. </Tooltip>
  165. </Fragment>
  166. ) : null}
  167. {subtitle && (
  168. <Fragment>
  169. <Divider />
  170. <Subtitle title={subtitle} isHoverable showOnlyOnOverflow delay={1000}>
  171. <Subtext>{subtitle}</Subtext>
  172. </Subtitle>
  173. </Fragment>
  174. )}
  175. <AttachmentsBadge group={group} />
  176. <UserFeedbackBadge group={group} project={project} />
  177. <ReplayBadge group={group} project={project} />
  178. </Flex>
  179. {issueTypeConfig.eventAndUserCounts.enabled && (
  180. <Fragment>
  181. <StatCount value={eventCount} aria-label={t('Event count')} />
  182. <GuideAnchor target="issue_header_stats">
  183. <StatCount value={userCount} aria-label={t('User count')} />
  184. </GuideAnchor>
  185. </Fragment>
  186. )}
  187. </HeaderGrid>
  188. </Header>
  189. <ActionBar isComplete={isComplete} role="banner">
  190. <GroupActions
  191. group={group}
  192. project={project}
  193. disabled={disableActions}
  194. event={event}
  195. />
  196. <WorkflowActions>
  197. <Workflow>
  198. {t('Priority')}
  199. <GroupPriority group={group} />
  200. </Workflow>
  201. <GuideAnchor target="issue_sidebar_owners" position="left">
  202. <Workflow>
  203. {t('Assignee')}
  204. <GroupHeaderAssigneeSelector
  205. group={group}
  206. project={project}
  207. event={event}
  208. />
  209. </Workflow>
  210. </GuideAnchor>
  211. </WorkflowActions>
  212. </ActionBar>
  213. </Fragment>
  214. );
  215. }
  216. const Header = styled('header')`
  217. background-color: ${p => p.theme.background};
  218. padding: ${space(1)} 24px;
  219. `;
  220. const HeaderGrid = styled('div')`
  221. display: grid;
  222. grid-template-columns: minmax(150px, 1fr) auto auto;
  223. column-gap: ${space(2)};
  224. align-items: center;
  225. `;
  226. const PrimaryTitle = styled(Tooltip)`
  227. overflow-x: hidden;
  228. text-overflow: ellipsis;
  229. white-space: nowrap;
  230. font-size: 20px;
  231. font-weight: ${p => p.theme.fontWeightBold};
  232. flex-shrink: 0;
  233. `;
  234. const SecondaryTitle = styled(Tooltip)<{isDefault: boolean}>`
  235. overflow-x: hidden;
  236. text-overflow: ellipsis;
  237. white-space: nowrap;
  238. font-style: ${p => (p.isDefault ? 'italic' : 'initial')};
  239. `;
  240. const StatTitle = styled('div')`
  241. display: block;
  242. color: ${p => p.theme.subText};
  243. font-size: ${p => p.theme.fontSizeSmall};
  244. font-weight: ${p => p.theme.fontWeightBold};
  245. line-height: 1;
  246. justify-self: flex-end;
  247. `;
  248. const StatLink = styled(Link)`
  249. color: ${p => p.theme.subText};
  250. text-decoration: ${p => (p['aria-disabled'] ? 'none' : 'underline')};
  251. text-decoration-style: dotted;
  252. `;
  253. const StatCount = styled(Count)`
  254. display: block;
  255. font-size: 20px;
  256. line-height: 1;
  257. text-align: right;
  258. `;
  259. const Subtext = styled('span')`
  260. color: ${p => p.theme.subText};
  261. `;
  262. const Subtitle = styled(Tooltip)`
  263. overflow: hidden;
  264. text-overflow: ellipsis;
  265. white-space: nowrap;
  266. `;
  267. const ActionBar = styled('div')<{isComplete: boolean}>`
  268. display: flex;
  269. justify-content: space-between;
  270. gap: ${space(1)};
  271. flex-wrap: wrap;
  272. padding: ${space(1)} 24px;
  273. border-bottom: 1px solid ${p => p.theme.translucentBorder};
  274. position: relative;
  275. transition: background 0.3s ease-in-out;
  276. background: ${p => (p.isComplete ? 'transparent' : p.theme.background)};
  277. &:before {
  278. z-index: -1;
  279. position: absolute;
  280. inset: 0;
  281. content: '';
  282. background: linear-gradient(
  283. to right,
  284. ${p => p.theme.background},
  285. ${p => Color(p.theme.success).lighten(0.5).alpha(0.15).string()}
  286. );
  287. }
  288. &:after {
  289. content: '';
  290. position: absolute;
  291. top: 0;
  292. right: 0;
  293. left: 24px;
  294. bottom: unset;
  295. height: 1px;
  296. background: ${p => p.theme.translucentBorder};
  297. }
  298. `;
  299. const WorkflowActions = styled('div')`
  300. display: flex;
  301. justify-content: flex-end;
  302. column-gap: ${space(2)};
  303. flex-wrap: wrap;
  304. @media (max-width: ${p => p.theme.breakpoints.large}) {
  305. justify-content: flex-start;
  306. }
  307. `;
  308. const Workflow = styled('div')`
  309. display: flex;
  310. gap: ${space(0.5)};
  311. color: ${p => p.theme.subText};
  312. align-items: center;
  313. `;