diff --git a/static/app/views/issueList/supergroups/supergroupDrawer.tsx b/static/app/views/issueList/supergroups/supergroupDrawer.tsx index 8e5abada896b48..92f0c58708b50a 100644 --- a/static/app/views/issueList/supergroups/supergroupDrawer.tsx +++ b/static/app/views/issueList/supergroups/supergroupDrawer.tsx @@ -51,6 +51,7 @@ import { useIssueSelectionSummary, } from 'sentry/views/issueList/issueSelectionContext'; import {SupergroupFeedback} from 'sentry/views/issueList/supergroups/supergroupFeedback'; +import {SupergroupTagPreview} from 'sentry/views/issueList/supergroups/supergroupTagPreview'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; @@ -140,6 +141,8 @@ export function SupergroupDetailDrawer({ + + {supergroup.group_ids.length > 0 && ( + color(theme.chart.getColorPalette(4).at(index)).alpha(0.8).toString(); + +export function SupergroupTagPreview({groupIds}: {groupIds: number[]}) { + const organization = useOrganization(); + const theme = useTheme(); + + const limitedGroupIds = useMemo( + () => groupIds.slice(0, MAX_GROUPS_FOR_TAGS), + [groupIds] + ); + + const tagResults = useApiQueries( + limitedGroupIds.map(groupId => [ + getApiUrl('/organizations/$organizationIdOrSlug/issues/$issueId/tags/', { + path: {organizationIdOrSlug: organization.slug, issueId: String(groupId)}, + }), + {query: {limit: 4}}, + ]), + {staleTime: 30_000, enabled: limitedGroupIds.length > 0} + ); + + const isPending = tagResults.some(r => r.isPending); + + const tagsToShow = useMemo(() => { + const tagMap = new Map< + string, + {totalValues: number; valueMap: Map} + >(); + + for (const result of tagResults) { + if (!result.data) { + continue; + } + for (const tag of result.data) { + let entry = tagMap.get(tag.key); + if (!entry) { + entry = {totalValues: 0, valueMap: new Map()}; + tagMap.set(tag.key, entry); + } + entry.totalValues += tag.totalValues; + for (const val of tag.topValues) { + const existing = entry.valueMap.get(val.value); + if (existing) { + existing.count += val.count; + } else { + entry.valueMap.set(val.value, {name: val.name, count: val.count}); + } + } + } + } + + const ordered: Array<{ + key: string; + topValues: Array<{count: number; name: string; value: string}>; + totalValues: number; + }> = []; + + for (const key of PRIORITY_TAGS) { + const entry = tagMap.get(key); + if (!entry || entry.valueMap.size === 0) { + continue; + } + const topValues = [...entry.valueMap.entries()] + .map(([value, {name, count}]) => ({value, name, count})) + .sort((a, b) => b.count - a.count) + .slice(0, 3); + ordered.push({key, totalValues: entry.totalValues, topValues}); + } + + return ordered.slice(0, 4); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPending, tagResults.length]); + + if (isPending) { + return ( + + + + + + + ); + } + + if (tagsToShow.length === 0) { + return null; + } + + return ( + + + {tagsToShow.map(tag => { + const topValue = tag.topValues[0]; + const topPct = + topValue && tag.totalValues > 0 + ? (topValue.count / tag.totalValues) * 100 + : 0; + const topPctStr = topPct < 0.5 ? '<1%' : `${Math.round(topPct)}%`; + + const segments = tag.topValues.map((val, idx) => ({ + name: val.name || t('(empty)'), + pct: tag.totalValues > 0 ? (val.count / tag.totalValues) * 100 : 0, + count: val.count, + color: tagBarColor(idx, theme), + })); + + const totalVisible = segments.reduce((sum, s) => sum + s.count, 0); + const hasOther = totalVisible < tag.totalValues; + const otherPct = 100 - segments.reduce((sum, s) => sum + Math.round(s.pct), 0); + + return ( + + {tag.key} + + {segments.map((seg, idx) => ( + + + {seg.name} + + {seg.pct < 0.5 ? '<1%' : `${Math.round(seg.pct)}%`} + + + ))} + {hasOther && ( + + + {t('Other')} + + {otherPct < 0.5 ? '<1%' : `${Math.round(otherPct)}%`} + + + )} + + + } + > + + + {tag.key} + + + {segments.map((seg, idx) => ( + + ))} + + + {topPctStr} + + {topValue?.name || t('(empty)')} + + + ); + })} + + + ); +} + +const TagPreviewGrid = styled('div')` + display: grid; + grid-template-columns: auto 80px min-content 1fr; + gap: 1px; + column-gap: ${p => p.theme.space.xs}; + font-size: ${p => p.theme.font.size.sm}; +`; + +const TagPreviewRow = styled('div')` + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; + padding: ${p => p.theme.space['2xs']} ${p => p.theme.space.sm}; + margin: 0 -${p => p.theme.space.sm}; + border-radius: ${p => p.theme.radius.md}; + + &:hover { + background: ${p => p.theme.tokens.background.tertiary}; + } +`; + +const TagSegmentedBar = styled('div')` + display: flex; + height: 8px; + width: 100%; + border-radius: 3px; + overflow: hidden; + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + box-shadow: inset 0 0 0 1px ${p => p.theme.tokens.border.transparent.neutral.muted}; + background: ${p => color(p.theme.colors.gray400).alpha(0.1).toString()}; +`; + +const TagBarSegment = styled('div')` + height: 100%; + min-width: 2px; +`; + +const TagTooltipLegend = styled('div')` + padding: ${p => p.theme.space.xs} ${p => p.theme.space.md}; +`; + +const TagLegendTitle = styled('div')` + font-weight: 600; + margin-bottom: ${p => p.theme.space.sm}; +`; + +const TagLegendGrid = styled('div')` + display: grid; + grid-template-columns: min-content auto min-content; + gap: ${p => p.theme.space.xs} ${p => p.theme.space.md}; + align-items: center; + text-align: left; +`; + +const TagLegendDot = styled('div')` + width: 10px; + height: 10px; + border-radius: 100%; +`; + +const TagLegendPct = styled('span')` + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +`;