import {Fragment, useEffect} from 'react'; import styled from '@emotion/styled'; import type {LocationDescriptor} from 'history'; import omit from 'lodash/omit'; import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import Badge from 'sentry/components/badge/badge'; import FeatureBadge from 'sentry/components/badge/featureBadge'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import Count from 'sentry/components/count'; import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle'; import EventMessage from 'sentry/components/events/eventMessage'; import {GroupStatusBadge} from 'sentry/components/group/inboxBadges/statusBadge'; import * as Layout from 'sentry/components/layouts/thirds'; import Link from 'sentry/components/links/link'; import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; import ReplayCountBadge from 'sentry/components/replays/replayCountBadge'; import {TabList} from 'sentry/components/tabs'; import {IconChat} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Event} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; import {IssueCategory, IssueType} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues'; import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay'; import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import GroupPriority from 'sentry/views/issueDetails/groupPriority'; import {useIssueDetailsHeader} from 'sentry/views/issueDetails/useIssueDetailsHeader'; import GroupActions from './actions'; import {Tab} from './types'; import {getGroupReprocessingStatus} from './utils'; type Props = { baseUrl: string; event: Event | null; group: Group; organization: Organization; project: Project; }; interface GroupHeaderTabsProps extends Pick<Props, 'baseUrl' | 'group' | 'project'> { disabledTabs: Tab[]; eventRoute: LocationDescriptor; } export function GroupHeaderTabs({ baseUrl, disabledTabs, eventRoute, group, project, }: GroupHeaderTabsProps) { const organization = useOrganization(); const location = useLocation(); const {getReplayCountForIssue} = useReplayCountForIssues({ statsPeriod: '90d', }); const replaysCount = getReplayCountForIssue(group.id, group.issueCategory); // omit `sort` param from the URLs because it persists from the issues list, // which can cause the tab content rendering to break const queryParams = omit(location.query, ['sort']); const projectFeatures = new Set(project ? project.features : []); const organizationFeatures = new Set(organization ? organization.features : []); const hasSimilarView = projectFeatures.has('similarity-view'); const hasEventAttachments = organizationFeatures.has('event-attachments'); const hasReplaySupport = organizationFeatures.has('session-replay') && projectCanLinkToReplay(organization, project); const issueTypeConfig = getConfigForIssueType(group, project); useRouteAnalyticsParams({ group_has_replay: (replaysCount ?? 0) > 0, }); useEffect(() => { if (group.issueType === IssueType.REPLAY_HYDRATION_ERROR) { trackAnalytics('replay.hydration-error.issue-details-opened', {organization}); } }, [group.issueType, organization]); return ( <StyledTabList hideBorder> <TabList.Item key={Tab.DETAILS} disabled={disabledTabs.includes(Tab.DETAILS)} to={`${baseUrl}${location.search}`} > {t('Details')} </TabList.Item> <TabList.Item key={Tab.ACTIVITY} textValue={t('Activity')} disabled={disabledTabs.includes(Tab.ACTIVITY)} to={{pathname: `${baseUrl}activity/`, query: queryParams}} > {t('Activity')} <IconBadge> {group.numComments} <IconChat size="xs" /> </IconBadge> </TabList.Item> <TabList.Item key={Tab.USER_FEEDBACK} textValue={t('User Feedback')} hidden={!issueTypeConfig.userFeedback.enabled} disabled={disabledTabs.includes(Tab.USER_FEEDBACK)} to={{pathname: `${baseUrl}feedback/`, query: queryParams}} > {t('User Feedback')} <Badge text={group.userReportCount} /> </TabList.Item> <TabList.Item key={Tab.ATTACHMENTS} hidden={!hasEventAttachments || !issueTypeConfig.attachments.enabled} disabled={disabledTabs.includes(Tab.ATTACHMENTS)} to={{pathname: `${baseUrl}attachments/`, query: queryParams}} > {t('Attachments')} </TabList.Item> <TabList.Item key={Tab.TAGS} hidden={!issueTypeConfig.tagsTab.enabled} disabled={disabledTabs.includes(Tab.TAGS)} to={{pathname: `${baseUrl}tags/`, query: queryParams}} > {t('Tags')} </TabList.Item> <TabList.Item key={Tab.EVENTS} hidden={!issueTypeConfig.events.enabled} disabled={disabledTabs.includes(Tab.EVENTS)} to={eventRoute} > {group.issueCategory === IssueCategory.ERROR ? t('All Events') : t('Sampled Events')} </TabList.Item> <TabList.Item key={Tab.MERGED} hidden={!issueTypeConfig.mergedIssues.enabled} disabled={disabledTabs.includes(Tab.MERGED)} to={{pathname: `${baseUrl}merged/`, query: queryParams}} > {t('Merged Issues')} </TabList.Item> <TabList.Item key={Tab.SIMILAR_ISSUES} hidden={!hasSimilarView || !issueTypeConfig.similarIssues.enabled} disabled={disabledTabs.includes(Tab.SIMILAR_ISSUES)} to={{pathname: `${baseUrl}similar/`, query: queryParams}} > {t('Similar Issues')} </TabList.Item> <TabList.Item key={Tab.REPLAYS} textValue={t('Replays')} hidden={!hasReplaySupport || !issueTypeConfig.replays.enabled} to={{pathname: `${baseUrl}replays/`, query: queryParams}} > {t('Replays')} <ReplayCountBadge count={replaysCount} /> </TabList.Item> </StyledTabList> ); } function GroupHeader({baseUrl, group, organization, event, project}: Props) { const location = useLocation(); const groupReprocessingStatus = getGroupReprocessingStatus(group); const { disabledTabs, message, eventRoute, disableActions, shortIdBreadcrumb, className, } = useIssueDetailsHeader({ group, groupReprocessingStatus, baseUrl, project, }); const {userCount} = group; const issueTypeConfig = getConfigForIssueType(group, project); const NEW_ISSUE_TYPES = [IssueType.REPLAY_HYDRATION_ERROR]; // adds a "new" banner next to the title return ( <Layout.Header> <div className={className}> <BreadcrumbActionWrapper> <Breadcrumbs crumbs={[ { label: 'Issues', to: { pathname: `/organizations/${organization.slug}/issues/`, // Sanitize sort queries from query query: omit(location.query, 'sort'), }, }, {label: shortIdBreadcrumb}, ]} /> <GroupActions group={group} project={project} disabled={disableActions} event={event} query={location.query} /> </BreadcrumbActionWrapper> <HeaderRow> <TitleWrapper> <TitleHeading> {NEW_ISSUE_TYPES.includes(group.issueType) && ( <StyledFeatureBadge type="new" /> )} <h3> <StyledEventOrGroupTitle data={group} /> </h3> <GroupStatusBadge status={group.status} substatus={group.substatus} fontSize="md" /> </TitleHeading> <EventMessage data={group} message={message} level={group.level} levelIndicatorSize="11px" type={group.type} showUnhandled={group.isUnhandled} /> </TitleWrapper> <StatsWrapper> {issueTypeConfig.stats.enabled && ( <Fragment> <GuideAnchor target="issue_header_stats"> <div className="count"> <h6 className="nav-header">{t('Events')}</h6> <Link disabled={disableActions} to={eventRoute}> <Count className="count" value={group.count} /> </Link> </div> </GuideAnchor> <div className="count"> <h6 className="nav-header">{t('Users')}</h6> {userCount !== 0 ? ( <Link disabled={disableActions} to={`${baseUrl}tags/user/${location.search}`} > <Count className="count" value={userCount} /> </Link> ) : ( <span>0</span> )} </div> </Fragment> )} <PriorityContainer> <h6 className="nav-header">{t('Priority')}</h6> <GroupPriority group={group} /> </PriorityContainer> </StatsWrapper> </HeaderRow> {/* Environment picker for mobile */} <HeaderRow className="hidden-sm hidden-md hidden-lg"> <EnvironmentPageFilter position="bottom-end" /> </HeaderRow> <GroupHeaderTabs {...{baseUrl, disabledTabs, eventRoute, group, project}} /> </div> </Layout.Header> ); } export default GroupHeader; const BreadcrumbActionWrapper = styled('div')` display: flex; flex-direction: row; justify-content: space-between; gap: ${space(1)}; align-items: center; `; const HeaderRow = styled('div')` display: flex; gap: ${space(2)}; justify-content: space-between; margin-top: ${space(2)}; @media (max-width: ${p => p.theme.breakpoints.small}) { flex-direction: column; } `; const TitleWrapper = styled('div')` @media (min-width: ${p => p.theme.breakpoints.small}) { max-width: 65%; } `; const TitleHeading = styled('div')` display: flex; line-height: 2; gap: ${space(1)}; `; const StyledEventOrGroupTitle = styled(EventOrGroupTitle)` font-size: inherit; `; const StatsWrapper = styled('div')` display: flex; gap: calc(${space(3)} + ${space(3)}); @media (min-width: ${p => p.theme.breakpoints.small}) { justify-content: flex-end; } `; const IconBadge = styled(Badge)` display: flex; align-items: center; gap: ${space(0.5)}; `; const StyledTabList = styled(TabList)` margin-top: ${space(2)}; `; const PriorityContainer = styled('div')` /* Ensures that the layout doesn't shift when changing priority */ min-width: 80px; `; const StyledFeatureBadge = styled(FeatureBadge)` align-items: flex-start; `;