diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx index a51546538a3fe8..4ee6b18374ac41 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx @@ -24,9 +24,11 @@ import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import usePrevious from 'sentry/utils/usePrevious'; +import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; import GroupEventDetailsContent from 'sentry/views/issueDetails/groupEventDetails/groupEventDetailsContent'; import GroupEventHeader from 'sentry/views/issueDetails/groupEventHeader'; import GroupSidebar from 'sentry/views/issueDetails/groupSidebar'; +import StreamlinedSidebar from 'sentry/views/issueDetails/streamline/sidebar'; import ReprocessingProgress from '../reprocessingProgress'; import { @@ -72,6 +74,8 @@ function GroupEventDetails(props: GroupEventDetailsProps) { const prevEvent = usePrevious(event); const hasStreamlinedUI = useHasStreamlinedUI(); + const [sidebarOpen, _] = useSyncedLocalStorageState('issue-details-sidebar-open', true); + // load the data useSentryAppComponentsData({projectId}); @@ -172,6 +176,7 @@ function GroupEventDetails(props: GroupEventDetailsProps) { {groupReprocessingStatus === ReprocessingStatus.REPROCESSING ? ( - - - + {hasStreamlinedUI ? ( + sidebarOpen ? ( + + + + ) : null + ) : ( + + + + )} )} @@ -214,7 +227,10 @@ function GroupEventDetails(props: GroupEventDetailsProps) { ); } -const StyledLayoutBody = styled(Layout.Body)<{hasStreamlinedUi: boolean}>` +const StyledLayoutBody = styled(Layout.Body)<{ + hasStreamlinedUi: boolean; + sidebarOpen: boolean; +}>` /* Makes the borders align correctly */ padding: 0 !important; @media (min-width: ${p => p.theme.breakpoints.large}) { @@ -226,6 +242,7 @@ const StyledLayoutBody = styled(Layout.Body)<{hasStreamlinedUi: boolean}>` css` @media (min-width: ${p.theme.breakpoints.large}) { gap: ${space(2)}; + display: ${p.sidebarOpen ? 'grid' : 'block'}; } `} `; diff --git a/static/app/views/issueDetails/streamline/activitySection.tsx b/static/app/views/issueDetails/streamline/activitySection.tsx new file mode 100644 index 00000000000000..20a842d671c8ee --- /dev/null +++ b/static/app/views/issueDetails/streamline/activitySection.tsx @@ -0,0 +1,50 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import Timeline from 'sentry/components/timeline'; +import TimeSince from 'sentry/components/timeSince'; +import type {Group} from 'sentry/types/group'; +import useOrganization from 'sentry/utils/useOrganization'; +import {groupActivityTypeIconMapping} from 'sentry/views/issueDetails/streamline/groupActivityIcons'; +import getGroupActivityItem from 'sentry/views/issueDetails/streamline/groupActivityItem'; + +function StreamlinedActivitySection({group}: {group: Group}) { + const organization = useOrganization(); + + return ( + + + {group.activity.map(item => { + const authorName = item.user ? item.user.name : 'Sentry'; + const {title, message} = getGroupActivityItem( + item, + organization, + group.project.id, + {authorName} + ); + + const Icon = groupActivityTypeIconMapping[item.type]?.Component ?? null; + + return ( + } + icon={ + Icon && + } + key={item.id} + > + {message} + + ); + })} + + + ); +} + +const Author = styled('span')` + font-weight: ${p => p.theme.fontWeightBold}; +`; + +export default StreamlinedActivitySection; diff --git a/static/app/views/issueDetails/streamline/groupActivityIcons.tsx b/static/app/views/issueDetails/streamline/groupActivityIcons.tsx new file mode 100644 index 00000000000000..839b1bcfa4e63b --- /dev/null +++ b/static/app/views/issueDetails/streamline/groupActivityIcons.tsx @@ -0,0 +1,67 @@ +import { + IconAdd, + IconCheckmark, + IconClose, + IconDelete, + IconEdit, + IconFile, + IconFire, + IconFlag, + IconGraph, + IconLock, + IconMute, + IconNext, + IconPlay, + IconPrevious, + IconRefresh, + IconUnsubscribed, + IconUser, +} from 'sentry/icons'; +import {GroupActivityType} from 'sentry/types/group'; + +interface IconWithDefaultProps { + Component: React.ComponentType | null; + defaultProps: {locked?: boolean; type?: string}; +} + +export const groupActivityTypeIconMapping: Record< + GroupActivityType, + IconWithDefaultProps +> = { + [GroupActivityType.NOTE]: {Component: IconFile, defaultProps: {}}, + [GroupActivityType.SET_RESOLVED]: {Component: IconCheckmark, defaultProps: {}}, + [GroupActivityType.SET_RESOLVED_BY_AGE]: {Component: IconCheckmark, defaultProps: {}}, + [GroupActivityType.SET_RESOLVED_IN_RELEASE]: { + Component: IconCheckmark, + defaultProps: {}, + }, + [GroupActivityType.SET_RESOLVED_IN_COMMIT]: { + Component: IconCheckmark, + defaultProps: {}, + }, + [GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST]: { + Component: IconCheckmark, + defaultProps: {}, + }, + [GroupActivityType.SET_UNRESOLVED]: {Component: IconClose, defaultProps: {}}, + [GroupActivityType.SET_IGNORED]: {Component: IconMute, defaultProps: {}}, + [GroupActivityType.SET_PUBLIC]: {Component: IconLock, defaultProps: {}}, + [GroupActivityType.SET_PRIVATE]: {Component: IconLock, defaultProps: {locked: true}}, + [GroupActivityType.SET_REGRESSION]: {Component: IconFire, defaultProps: {}}, + [GroupActivityType.CREATE_ISSUE]: {Component: IconAdd, defaultProps: {}}, + [GroupActivityType.UNMERGE_SOURCE]: {Component: IconPrevious, defaultProps: {}}, + [GroupActivityType.UNMERGE_DESTINATION]: {Component: IconPrevious, defaultProps: {}}, + [GroupActivityType.FIRST_SEEN]: {Component: IconFlag, defaultProps: {}}, + [GroupActivityType.ASSIGNED]: {Component: IconUser, defaultProps: {}}, + [GroupActivityType.UNASSIGNED]: {Component: IconUnsubscribed, defaultProps: {}}, + [GroupActivityType.MERGE]: {Component: IconNext, defaultProps: {}}, + [GroupActivityType.REPROCESS]: {Component: IconRefresh, defaultProps: {}}, + [GroupActivityType.MARK_REVIEWED]: {Component: IconCheckmark, defaultProps: {}}, + [GroupActivityType.AUTO_SET_ONGOING]: {Component: IconPlay, defaultProps: {}}, + [GroupActivityType.SET_ESCALATING]: { + Component: IconGraph, + defaultProps: {type: 'area'}, + }, + [GroupActivityType.SET_PRIORITY]: {Component: IconEdit, defaultProps: {}}, + [GroupActivityType.DELETED_ATTACHMENT]: {Component: IconDelete, defaultProps: {}}, +}; diff --git a/static/app/views/issueDetails/streamline/groupActivityItem.tsx b/static/app/views/issueDetails/streamline/groupActivityItem.tsx new file mode 100644 index 00000000000000..80124ffbafe365 --- /dev/null +++ b/static/app/views/issueDetails/streamline/groupActivityItem.tsx @@ -0,0 +1,701 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; +import moment from 'moment-timezone'; + +import CommitLink from 'sentry/components/commitLink'; +import {DateTime} from 'sentry/components/dateTime'; +import Duration from 'sentry/components/duration'; +import ExternalLink from 'sentry/components/links/externalLink'; +import Link from 'sentry/components/links/link'; +import PullRequestLink from 'sentry/components/pullRequestLink'; +import Version from 'sentry/components/version'; +import {t, tct, tn} from 'sentry/locale'; +import type { + GroupActivity, + GroupActivityAssigned, + GroupActivitySetEscalating, + GroupActivitySetIgnored, +} from 'sentry/types/group'; +import {GroupActivityType} from 'sentry/types/group'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import type {User} from 'sentry/types/user'; +import {useTeamsById} from 'sentry/utils/useTeamsById'; +import {isSemverRelease} from 'sentry/utils/versions/isSemverRelease'; + +export default function getGroupActivityItem( + activity: GroupActivity, + organization: Organization, + projectId: Project['id'], + author: React.ReactNode +) { + const issuesLink = `/organizations/${organization.slug}/issues/`; + + const {teams} = useTeamsById( + activity.type === GroupActivityType.ASSIGNED && activity.data.assigneeType === 'team' + ? {ids: [activity.data.assignee]} + : undefined + ); + + function getIgnoredMessage(data: GroupActivitySetIgnored['data']): { + message: JSX.Element | string | null; + title: JSX.Element | string; + } { + if (data.ignoreDuration) { + return { + title: t('Archived'), + message: tct('by [author] for [duration]', { + author, + duration: , + }), + }; + } + + if (data.ignoreCount && data.ignoreWindow) { + return { + title: t('Archived'), + message: tct('by [author] until it happens [count] time(s) in [duration]', { + author, + count: data.ignoreCount, + duration: , + }), + }; + } + + if (data.ignoreCount) { + return { + title: t('Archived'), + message: tct('by [author] until it happens [count] time(s)', { + author, + count: data.ignoreCount, + }), + }; + } + + if (data.ignoreUserCount && data.ignoreUserWindow) { + return { + title: t('Archived'), + message: tct('by [author] until it affects [count] user(s) in [duration]', { + author, + count: data.ignoreUserCount, + duration: , + }), + }; + } + + if (data.ignoreUserCount) { + return { + title: t('Archived'), + message: tct('by [author] until it affects [count] user(s)', { + author, + count: data.ignoreUserCount, + }), + }; + } + + if (data.ignoreUntil) { + return { + title: t('Archived'), + message: tct('by [author] until [date]', { + author, + date: , + }), + }; + } + if (data.ignoreUntilEscalating) { + return { + title: t('Archived'), + message: tct('by [author] until it escalates', { + author, + }), + }; + } + + return { + title: t('Archived'), + message: tct('by [author] forever', { + author, + }), + }; + } + + function getAssignedMessage(assignedActivity: GroupActivityAssigned) { + const {data} = assignedActivity; + let assignee: string | User | undefined = undefined; + + if (data.assigneeType === 'team') { + const team = teams.find(({id}) => id === data.assignee); + // TODO: could show a loading indicator if the team is loading + assignee = team ? `#${team.slug}` : ''; + } else if (activity.user && data.assignee === activity.user.id) { + assignee = t('themselves'); + } else if (data.assigneeType === 'user' && data.assigneeEmail) { + assignee = data.assigneeEmail; + } else { + assignee = t('an unknown user'); + } + + const isAutoAssigned = [ + 'projectOwnership', + 'codeowners', + 'suspectCommitter', + ].includes(data.integration as string); + + const integrationName: Record< + NonNullable, + string + > = { + msteams: t('Microsoft Teams'), + slack: t('Slack'), + projectOwnership: t('Ownership Rule'), + codeowners: t('Codeowners Rule'), + suspectCommitter: t('Suspect Commit'), + }; + + return { + title: isAutoAssigned ? t('Auto-Assigned') : t('Assigned'), + message: tct('by [author] to [assignee]. [assignedReason]', { + author, + assignee, + assignedReason: data.integration && integrationName[data.integration] && ( + + {t('Assigned via %s', integrationName[data.integration])} + {data.rule && ( + + : {data.rule} + + )} + + ), + }), + }; + } + + function getEscalatingMessage(data: GroupActivitySetEscalating['data']): { + message: JSX.Element | string | null; + title: JSX.Element | string; + } { + if (data.forecast) { + return { + title: t('Escalated'), + message: tct('by [author] because over [forecast] [event] happened in an hour', { + author, + forecast: data.forecast, + event: data.forecast === 1 ? 'event' : 'events', + }), + }; + } + + if (data.expired_snooze) { + if (data.expired_snooze.count && data.expired_snooze.window) { + return { + title: t('Escalated'), + message: tct('by [author] because [count] [event] happened in [duration]', { + author, + count: data.expired_snooze.count, + event: data.expired_snooze.count === 1 ? 'event' : 'events', + duration: , + }), + }; + } + + if (data.expired_snooze.count) { + return { + title: t('Escalated'), + message: tct('by [author] because [count] [event] happened', { + author, + count: data.expired_snooze.count, + event: data.expired_snooze.count === 1 ? 'event' : 'events', + }), + }; + } + + if (data.expired_snooze.user_count && data.expired_snooze.user_window) { + return { + title: t('Escalated'), + message: tct('by [author] because [count] [user] affected in [duration]', { + author, + count: data.expired_snooze.user_count, + user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were', + duration: , + }), + }; + } + + if (data.expired_snooze.user_count) { + return { + title: t('Escalated'), + message: tct('by [author] because [count] [user] affected', { + author, + count: data.expired_snooze.user_count, + user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were', + }), + }; + } + + if (data.expired_snooze.until) { + return { + title: t('Escalated'), + message: tct('by [author] because [date] passed', { + author, + date: , + }), + }; + } + } + + return { + title: t('Escalated'), + message: tct('by [author]', {author}), + }; // should not reach this + } + + function renderContent(): { + message: JSX.Element | string | null; + title: JSX.Element | string; + } { + switch (activity.type) { + case GroupActivityType.NOTE: + return { + title: tct('[author]', {author}), + message: activity.data.text, + }; + case GroupActivityType.SET_RESOLVED: + let resolvedMessage: JSX.Element; + if ('integration_id' in activity.data && activity.data.integration_id) { + resolvedMessage = tct('by [author] via [integration]', { + integration: ( + + {activity.data.provider} + + ), + author, + }); + } else { + resolvedMessage = tct('by [author]', {author}); + } + return { + title: t('Resolved'), + message: resolvedMessage, + }; + case GroupActivityType.SET_RESOLVED_BY_AGE: + return { + title: t('Resolved'), + message: tct('by [author] due to inactivity', { + author, + }), + }; + case GroupActivityType.SET_RESOLVED_IN_RELEASE: + // Resolved in the next release + if ('current_release_version' in activity.data) { + const currentVersion = activity.data.current_release_version; + return { + title: t('Resolved'), + message: tct('by [author] in releases greater than [version] [semver]', { + author, + version: ( + + ), + semver: isSemverRelease(currentVersion) ? t('(semver)') : t('(non-semver)'), + }), + }; + } + const version = activity.data.version; + return { + title: t('Resolved'), + message: version + ? tct('by [author] in [version] [semver]', { + author, + version: ( + + ), + semver: isSemverRelease(version) ? t('(semver)') : t('(non-semver)'), + }) + : tct('by [author] in the upcoming release', { + author, + }), + }; + case GroupActivityType.SET_RESOLVED_IN_COMMIT: + const deployedReleases = (activity.data.commit?.releases || []) + .filter(r => r.dateReleased !== null) + .sort( + (a, b) => moment(a.dateReleased).valueOf() - moment(b.dateReleased).valueOf() + ); + if (deployedReleases.length === 1 && activity.data.commit) { + return { + title: t('Resolved'), + message: tct( + 'by [author] in [version]. This commit was released in [release]', + { + author, + version: ( + + ), + release: ( + + ), + } + ), + }; + } + if (deployedReleases.length > 1 && activity.data.commit) { + return { + title: t('Resolved'), + message: tct( + 'by [author] in [version]. This commit was released in [release] and [otherCount] others', + { + author, + otherCount: deployedReleases.length - 1, + version: ( + + ), + release: ( + + ), + } + ), + }; + } + if (activity.data.commit) { + return { + title: t('Resolved'), + message: tct('by [author] in [commit]', { + author, + commit: ( + + ), + }), + }; + } + return { + title: t('Resolved'), + message: tct('by [author] in a commit', {author}), + }; + case GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST: { + const {data} = activity; + const {pullRequest} = data; + return { + title: t('Resolved'), + message: tct('[author] has created a PR for this issue: [pullRequest]', { + author, + pullRequest: pullRequest ? ( + + ) : ( + t('PR not available') + ), + }), + }; + } + case GroupActivityType.SET_UNRESOLVED: { + // TODO(nisanthan): Remove after migrating records to SET_ESCALATING + const {data} = activity; + if ('forecast' in data && data.forecast) { + return { + title: t('Escalated'), + message: tct( + ' by [author] because over [forecast] [event] happened in an hour', + { + author, + forecast: data.forecast, + event: data.forecast === 1 ? 'event' : 'events', + } + ), + }; + } + if ('integration_id' in data && data.integration_id) { + return { + title: t('Unresolved'), + message: tct('by [author] via [integration]', { + integration: ( + + {data.provider} + + ), + author, + }), + }; + } + return { + title: t('Unresolved'), + message: tct('by [author] as unresolved', {author}), + }; + } + case GroupActivityType.SET_IGNORED: { + const {data} = activity; + return getIgnoredMessage(data); + } + case GroupActivityType.SET_PUBLIC: + return { + title: t('Made Public'), + message: tct('by [author]', {author}), + }; + case GroupActivityType.SET_PRIVATE: + return { + title: t('Made Private'), + message: tct('by [author]', {author}), + }; + case GroupActivityType.SET_REGRESSION: { + const {data} = activity; + let subtext: React.ReactNode = null; + if (data.version && data.resolved_in_version && 'follows_semver' in data) { + subtext = ( + + {tct( + '[regressionVersion] is greater than or equal to [resolvedVersion] compared via [comparison]', + { + regressionVersion: ( + + ), + resolvedVersion: ( + + ), + comparison: data.follows_semver ? t('semver') : t('release date'), + } + )} + + ); + } + + return { + title: t('Regressed'), + message: data.version + ? tct('by [author] in [version]. [subtext]', { + author, + version: ( + + ), + subtext, + }) + : tct('by [author]', { + author, + subtext, + }), + }; + } + case GroupActivityType.CREATE_ISSUE: { + const {data} = activity; + return { + title: t('Created Issue'), + message: tct('by [author] on [provider] titled [title]', { + author, + provider: data.provider, + title: {data.title}, + }), + }; + } + case GroupActivityType.MERGE: + return { + title: t('Merged'), + message: tn( + '%1$s issue into this issue by %2$s', + '%1$s issues into this issue by %2$s', + activity.data.issues.length, + author + ), + }; + case GroupActivityType.UNMERGE_SOURCE: { + const {data} = activity; + const {destination, fingerprints} = data; + return { + title: t('Unmerged'), + message: tn( + '%1$s fingerprint to %3$s by %2$s', + '%1$s fingerprints to %3$s by %2$s', + fingerprints.length, + author, + destination ? ( + + {destination.shortId} + + ) : ( + t('a group') + ) + ), + }; + } + case GroupActivityType.UNMERGE_DESTINATION: { + const {data} = activity; + const {source, fingerprints} = data; + return { + title: t('Unmerged'), + message: tn( + '%1$s fingerprint to %3$s by %2$s', + '%1$s fingerprints to %3$s by %2$s', + fingerprints.length, + author, + source ? ( + + {source.shortId} + + ) : ( + t('a group') + ) + ), + }; + } + case GroupActivityType.FIRST_SEEN: + if (activity.data.priority) { + return { + title: t('First Seen'), + message: tct('Marked as [priority] priority', { + author, + priority: activity.data.priority, + }), + }; + } + return { + title: t('First Seen'), + message: null, + }; + case GroupActivityType.ASSIGNED: { + return getAssignedMessage(activity); + } + case GroupActivityType.UNASSIGNED: + return { + title: t('Unassigned'), + message: tct('by [author]', {author}), + }; + + case GroupActivityType.REPROCESS: { + const {data} = activity; + const {oldGroupId, eventCount} = data; + + return { + title: t('Resprocessed Events'), + message: tct('by [author]. [new-events]', { + author, + ['new-events']: ( + + {tn('See %s new event', 'See %s new events', eventCount)} + + ), + }), + }; + } + case GroupActivityType.MARK_REVIEWED: { + return { + title: t('Reviewed'), + message: tct('by [author]', { + author, + }), + }; + } + case GroupActivityType.AUTO_SET_ONGOING: { + return { + title: t('Marked As Ongoing'), + message: activity.data?.afterDays + ? tct('automatically by [author] after [afterDays] days', { + author, + afterDays: activity.data.afterDays, + }) + : tct('automatically by [author]', { + author, + }), + }; + } + case GroupActivityType.SET_ESCALATING: { + return getEscalatingMessage(activity.data); + } + case GroupActivityType.SET_PRIORITY: { + const {data} = activity; + switch (data.reason) { + case 'escalating': + return { + title: t('Priority Updated'), + message: tct('by [author] to be [priority] after it escalated', { + author, + priority: data.priority, + }), + }; + case 'ongoing': + return { + title: t('Priority Updated'), + message: tct( + 'by [author] to be [priority] after it was marked as ongoing', + {author, priority: data.priority} + ), + }; + default: + return { + title: t('Priority Updated'), + message: tct('by [author] to be [priority]', { + author, + priority: data.priority, + }), + }; + } + } + case GroupActivityType.DELETED_ATTACHMENT: + return { + title: t('Attachment Deleted'), + message: tct('by [author]', {author}), + }; + default: + return {title: '', message: ''}; // should never hit (?) + } + } + return renderContent(); +} + +const Subtext = styled('div')` + font-size: ${p => p.theme.fontSizeSmall}; +`; + +const CodeWrapper = styled('div')` + overflow-wrap: anywhere; + font-size: ${p => p.theme.fontSizeSmall}; +`; + +const StyledRuleSpan = styled('span')` + font-family: ${p => p.theme.text.familyMono}; +`; diff --git a/static/app/views/issueDetails/streamline/header.tsx b/static/app/views/issueDetails/streamline/header.tsx index b03a48651d88b8..decfbcb2fb1149 100644 --- a/static/app/views/issueDetails/streamline/header.tsx +++ b/static/app/views/issueDetails/streamline/header.tsx @@ -4,6 +4,7 @@ import Color from 'color'; import Feature from 'sentry/components/acl/feature'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; +import {Button} from 'sentry/components/button'; import Count from 'sentry/components/count'; import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle'; import EventMessage from 'sentry/components/events/eventMessage'; @@ -16,6 +17,7 @@ import ParticipantList from 'sentry/components/group/streamlinedParticipantList' import Link from 'sentry/components/links/link'; import Version from 'sentry/components/version'; import VersionHoverCard from 'sentry/components/versionHoverCard'; +import {IconDashboard} from 'sentry/icons'; import {t} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; @@ -26,6 +28,7 @@ import type {Release} from 'sentry/types/release'; import {useApiQuery} from 'sentry/utils/queryClient'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; +import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; import GroupActions from 'sentry/views/issueDetails/actions/index'; import {Divider} from 'sentry/views/issueDetails/divider'; import GroupPriority from 'sentry/views/issueDetails/groupPriority'; @@ -74,6 +77,11 @@ export default function StreamlinedGroupHeader({ group, }); + const [sidebarOpen, setSidebarOpen] = useSyncedLocalStorageState( + 'issue-details-sidebar-open', + true + ); + const {disabledTabs, message, eventRoute, disableActions, shortIdBreadcrumb} = useIssueDetailsHeader({ group, @@ -192,32 +200,42 @@ export default function StreamlinedGroupHeader({ event={event} query={location.query} /> - - - {t('Priority')} - - - - {t('Assignee')} - - - {group.participants.length > 0 && ( + + - {t('Participants')} - + {t('Priority')} + - )} - {displayUsers.length > 0 && ( - {t('Viewers')} - + {t('Assignee')} + - )} - + {group.participants.length > 0 && ( + + {t('Participants')} + + + )} + {displayUsers.length > 0 && ( + + {t('Viewers')} + + + )} + + +