import {Fragment} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import ErrorBoundary from 'sentry/components/errorBoundary'; import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle'; import ErrorLevel from 'sentry/components/events/errorLevel'; import GlobalSelectionLink from 'sentry/components/globalSelectionLink'; import {IconMute, IconStar} from 'sentry/icons'; import {tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Group, GroupTombstoneHelper, Level, Organization} from 'sentry/types'; import {Event} from 'sentry/types/event'; import {getLocation, getMessage, isTombstone} from 'sentry/utils/events'; import {useLocation} from 'sentry/utils/useLocation'; import withOrganization from 'sentry/utils/withOrganization'; import {TagAndMessageWrapper} from 'sentry/views/issueDetails/unhandledTag'; import EventTitleError from './eventTitleError'; type Size = 'small' | 'normal'; interface EventOrGroupHeaderProps { data: Event | Group | GroupTombstoneHelper; organization: Organization; /* is issue breakdown? */ grouping?: boolean; hideIcons?: boolean; hideLevel?: boolean; index?: number; /** Group link clicked */ onClick?: () => void; query?: string; size?: Size; source?: string; } /** * Displays an event or group/issue title (i.e. in Stream) */ function EventOrGroupHeader({ data, index, organization, query, onClick, hideIcons, hideLevel, size = 'normal', grouping = false, source, }: EventOrGroupHeaderProps) { const location = useLocation(); function getTitleChildren() { const {level, status, isBookmarked, hasSeen} = data as Group; return ( <Fragment> {!hideLevel && level && <GroupLevel level={level} />} {!hideIcons && status === 'ignored' && !organization.features.includes('escalating-issues') && ( <IconWrapper> <IconMute color="red400" /> </IconWrapper> )} {!hideIcons && isBookmarked && ( <IconWrapper> <IconStar isSolid color="yellow400" /> </IconWrapper> )} <ErrorBoundary customComponent={<EventTitleError />} mini> <StyledEventOrGroupTitle data={data} organization={organization} // hasSeen is undefined for GroupTombstone hasSeen={hasSeen === undefined ? true : hasSeen} withStackTracePreview grouping={grouping} query={query} /> </ErrorBoundary> </Fragment> ); } function getTitle() { const {id, status} = data as Group; const {eventID, groupID} = data as Event; const hasEscalatingIssues = organization.features.includes('escalating-issues'); const commonEleProps = { 'data-test-id': status === 'resolved' ? 'resolved-issue' : null, style: status === 'resolved' && !hasEscalatingIssues ? {textDecoration: 'line-through'} : undefined, }; if (isTombstone(data)) { return ( <TitleWithoutLink {...commonEleProps}>{getTitleChildren()}</TitleWithoutLink> ); } return ( <TitleWithLink {...commonEleProps} to={{ pathname: `/organizations/${organization.slug}/issues/${ eventID ? groupID : id }/${eventID ? `events/${eventID}/` : ''}`, query: { referrer: source || 'event-or-group-header', stream_index: index, query, // This adds sort to the query if one was selected from the // issues list page ...(location.query.sort !== undefined ? {sort: location.query.sort} : {}), // This appends _allp to the URL parameters if they have no // project selected ("all" projects included in results). This is // so that when we enter the issue details page and lock them to // a project, we can properly take them back to the issue list // page with no project selected (and not the locked project // selected) ...(location.query.project !== undefined ? {} : {_allp: 1}), }, }} onClick={onClick} > {getTitleChildren()} </TitleWithLink> ); } const eventLocation = getLocation(data); const message = getMessage(data); return ( <div data-test-id="event-issue-header"> <Title>{getTitle()}</Title> {eventLocation && <Location size={size}>{eventLocation}</Location>} {message && ( <StyledTagAndMessageWrapper size={size}> {message && <Message>{message}</Message>} </StyledTagAndMessageWrapper> )} </div> ); } const truncateStyles = css` overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; `; const getMargin = ({size}: {size: Size}) => { if (size === 'small') { return 'margin: 0;'; } return 'margin: 0 0 5px'; }; const Title = styled('div')` margin-bottom: ${space(0.25)}; & em { font-size: ${p => p.theme.fontSizeMedium}; font-style: normal; font-weight: 300; color: ${p => p.theme.subText}; } `; const LocationWrapper = styled('div')` ${truncateStyles}; ${getMargin}; direction: rtl; text-align: left; font-size: ${p => p.theme.fontSizeMedium}; color: ${p => p.theme.subText}; span { direction: ltr; } `; function Location(props) { const {children, ...rest} = props; return ( <LocationWrapper {...rest}> {tct('in [location]', { location: <span>{children}</span>, })} </LocationWrapper> ); } const StyledTagAndMessageWrapper = styled(TagAndMessageWrapper)` ${getMargin}; line-height: 1.2; `; const Message = styled('div')` ${truncateStyles}; font-size: ${p => p.theme.fontSizeMedium}; `; const IconWrapper = styled('span')` position: relative; margin-right: 5px; `; const GroupLevel = styled(ErrorLevel)<{level: Level}>` position: absolute; left: -1px; width: 9px; height: 15px; border-radius: 0 3px 3px 0; `; const TitleWithLink = styled(GlobalSelectionLink)` display: inline-flex; `; const TitleWithoutLink = styled('span')` display: inline-flex; `; export default withOrganization(EventOrGroupHeader); const StyledEventOrGroupTitle = styled(EventOrGroupTitle)<{ hasSeen: boolean; }>` font-weight: ${p => (p.hasSeen ? 400 : 600)}; `;