diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 76f3c08ab11e75..5a76a6e29b2107 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -2697,6 +2697,11 @@ function buildRoutes(): RouteObject[] { () => import('sentry/views/issueList/issueViews/issueViewsList/issueViewsList') ), }, + { + path: 'dynamic-groups/', + component: make(() => import('sentry/views/issueList/pages/dynamicGrouping')), + deprecatedRouteProps: true, + }, { path: 'views/:viewId/', component: errorHandler(OverviewWrapper), diff --git a/static/app/views/issueList/pages/dynamicGrouping.tsx b/static/app/views/issueList/pages/dynamicGrouping.tsx new file mode 100644 index 00000000000000..a723b63c3b6bba --- /dev/null +++ b/static/app/views/issueList/pages/dynamicGrouping.tsx @@ -0,0 +1,726 @@ +import {Fragment, useEffect, useMemo, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Container, Flex} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {Breadcrumbs} from 'sentry/components/breadcrumbs'; +import {Alert} from 'sentry/components/core/alert'; +import {Button} from 'sentry/components/core/button'; +import {Checkbox} from 'sentry/components/core/checkbox'; +import {Disclosure} from 'sentry/components/core/disclosure'; +import {NumberInput} from 'sentry/components/core/input/numberInput'; +import {Link} from 'sentry/components/core/link'; +import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle'; +import EventMessage from 'sentry/components/events/eventMessage'; +import TimesTag from 'sentry/components/group/inboxBadges/timesTag'; +import UnhandledTag from 'sentry/components/group/inboxBadges/unhandledTag'; +import IssueReplayCount from 'sentry/components/group/issueReplayCount'; +import ProjectBadge from 'sentry/components/idBadge/projectBadge'; +import Placeholder from 'sentry/components/placeholder'; +import Redirect from 'sentry/components/redirect'; +import {IconChat, IconStar} from 'sentry/icons'; +import {t, tn} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {Group} from 'sentry/types/group'; +import {defined} from 'sentry/utils'; +import {getMessage, getTitle} from 'sentry/utils/events'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues'; +import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay'; +import useOrganization from 'sentry/utils/useOrganization'; +import {useUser} from 'sentry/utils/useUser'; +import {useUserTeams} from 'sentry/utils/useUserTeams'; + +const STORAGE_KEY = 'dynamic-grouping-cluster-data'; + +interface AssignedEntity { + email: string | null; + id: string; + name: string; + type: string; +} + +interface ClusterSummary { + assignedTo: AssignedEntity[]; + cluster_avg_similarity: number | null; + cluster_id: number; + cluster_min_similarity: number | null; + cluster_size: number | null; + description: string; + fixability_score: number | null; + group_ids: number[]; + issue_titles: string[]; + project_ids: number[]; + tags: string[]; + title: string; +} + +// Compact issue preview for dynamic grouping - no short ID or quick fix icon +function CompactIssuePreview({group}: {group: Group}) { + const organization = useOrganization(); + const {getReplayCountForIssue} = useReplayCountForIssues(); + + const showReplayCount = + organization.features.includes('session-replay') && + projectCanLinkToReplay(organization, group.project) && + group.issueCategory && + !!getReplayCountForIssue(group.id, group.issueCategory); + + const issuesPath = `/organizations/${organization.slug}/issues/`; + const {subtitle} = getTitle(group); + + const items = [ + group.project ? ( + + ) : null, + group.isUnhandled ? : null, + group.count ? ( + {tn('%s event', '%s events', group.count)} + ) : null, + group.lifetime || group.firstSeen || group.lastSeen ? ( + + ) : null, + group.numComments > 0 ? ( + + + {group.numComments} + + ) : null, + showReplayCount ? : null, + ].filter(defined); + + return ( + + + {group.isBookmarked && ( + + + + )} + + + + {subtitle && {subtitle}} + {items.length > 0 && ( + + {items.map((item, i) => ( + + {item} + {i < items.length - 1 ? : null} + + ))} + + )} + + ); +} + +// Fetch and display actual issues from the group_ids +function ClusterIssues({groupIds}: {groupIds: number[]}) { + const organization = useOrganization(); + + // Only fetch first 3 issues for preview + const previewGroupIds = groupIds.slice(0, 3); + + const {data: groups, isPending} = useApiQuery( + [ + `/organizations/${organization.slug}/issues/`, + { + query: { + group: previewGroupIds, + query: `issue.id:[${previewGroupIds.join(',')}]`, + }, + }, + ], + { + staleTime: 60000, // Cache for 1 minute + } + ); + + if (isPending) { + return ( + + {[0, 1, 2].map(i => ( + + ))} + + ); + } + + if (!groups || groups.length === 0) { + return null; + } + + return ( + + {groups.map(group => ( + + + + ))} + + ); +} + +// Individual cluster card +function ClusterCard({ + cluster, + onRemove, + isRemoving, +}: { + cluster: ClusterSummary; + isRemoving: boolean; + onRemove: (clusterId: number) => void; +}) { + const organization = useOrganization(); + + return ( + + + + {cluster.title} + {cluster.fixability_score !== null && ( + + {t('%s%% confidence', Math.round(cluster.fixability_score * 100))} + + )} + + + {cluster.cluster_size ?? cluster.group_ids.length} + + {tn('issue', 'issues', cluster.cluster_size ?? cluster.group_ids.length)} + + + + + + + {cluster.description} + + + + + {cluster.group_ids.length > 3 && ( + + {t('+ %s more similar issues', cluster.group_ids.length - 3)} + + )} + + + + + + + + + + + ); +} + +function DynamicGrouping() { + const organization = useOrganization(); + const user = useUser(); + const {teams} = useUserTeams(); + const [jsonInput, setJsonInput] = useState(''); + const [clusterData, setClusterData] = useState([]); + const [parseError, setParseError] = useState(null); + const [showInput, setShowInput] = useState(true); + const [filterByAssignedToMe, setFilterByAssignedToMe] = useState(true); + const [minFixabilityScore, setMinFixabilityScore] = useState(50); + const [removingClusterId, setRemovingClusterId] = useState(null); + + // Load from localStorage on mount + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + setClusterData(parsed); + setJsonInput(stored); + setShowInput(false); + } catch { + // If stored data is invalid, clear it + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const handleJsonSubmit = () => { + try { + const parsed = JSON.parse(jsonInput); + if (!Array.isArray(parsed)) { + setParseError(t('JSON must be an array of cluster summaries')); + return; + } + setClusterData(parsed); + setParseError(null); + setShowInput(false); + localStorage.setItem(STORAGE_KEY, jsonInput); + } catch (error) { + setParseError(error instanceof Error ? error.message : t('Invalid JSON format')); + } + }; + + const handleClear = () => { + setClusterData([]); + setJsonInput(''); + setParseError(null); + setShowInput(true); + localStorage.removeItem(STORAGE_KEY); + }; + + const handleRemoveCluster = (clusterId: number) => { + // Start animation + setRemovingClusterId(clusterId); + + // Wait for animation to complete before removing + setTimeout(() => { + const updatedClusters = clusterData.filter( + cluster => cluster.cluster_id !== clusterId + ); + setClusterData(updatedClusters); + setRemovingClusterId(null); + + // Update localStorage with the new data + if (updatedClusters.length > 0) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedClusters)); + } else { + localStorage.removeItem(STORAGE_KEY); + } + }, 300); // Match the animation duration + }; + + // Check if a cluster has at least one issue assigned to the current user or their teams + const isClusterAssignedToMe = useMemo(() => { + const userId = user.id; + const teamIds = teams.map(team => team.id); + + return (cluster: ClusterSummary) => { + // If no assignedTo data, include the cluster + if (!cluster.assignedTo || cluster.assignedTo.length === 0) { + return false; + } + + // Check if any assigned entity matches the user or their teams + return cluster.assignedTo.some(entity => { + if (entity.type === 'user' && entity.id === userId) { + return true; + } + if (entity.type === 'team' && teamIds.includes(entity.id)) { + return true; + } + return false; + }); + }; + }, [user.id, teams]); + + // Filter and sort clusters by fixability score (descending) + const filteredAndSortedClusters = useMemo(() => { + return [...clusterData] + .filter((cluster: ClusterSummary) => { + // Filter by fixability score - hide clusters below threshold + const fixabilityScore = (cluster.fixability_score ?? 0) * 100; + if (fixabilityScore < minFixabilityScore) { + return false; + } + + // If "Assigned to Me" filter is enabled, only show clusters assigned to the user + if (filterByAssignedToMe) { + return isClusterAssignedToMe(cluster); + } + return true; + }) + .sort( + (a: ClusterSummary, b: ClusterSummary) => + (b.fixability_score ?? 0) - (a.fixability_score ?? 0) + ); + }, [clusterData, filterByAssignedToMe, minFixabilityScore, isClusterAssignedToMe]); + + const totalIssues = useMemo(() => { + return filteredAndSortedClusters.reduce( + (sum: number, c: ClusterSummary) => sum + (c.cluster_size ?? c.group_ids.length), + 0 + ); + }, [filteredAndSortedClusters]); + + const hasTopIssuesUI = organization.features.includes('top-issues-ui'); + if (!hasTopIssuesUI) { + return ; + } + + return ( + + + + + +
+ {t('Top Issues')} +
+ {clusterData.length > 0 && !showInput && ( + + + + + )} +
+
+ + {showInput || clusterData.length === 0 ? ( + + + + {t('Paste cluster summaries JSON data below:')} + + setJsonInput(e.target.value)} + placeholder={t('Paste JSON array here...')} + rows={10} + /> + {parseError && {parseError}} + + + {clusterData.length > 0 && ( + + )} + + + + ) : ( + + + + {tn( + 'Viewing %s issue in %s cluster', + 'Viewing %s issues across %s clusters', + totalIssues, + filteredAndSortedClusters.length + )} + + + + + + + + {t('More Filters')} + + + + + + setFilterByAssignedToMe(e.target.checked)} + aria-label={t('Show only issues assigned to me')} + size="sm" + /> + + {t('Only show issues assigned to me')} + + + + + {t('Minimum fixability score (%)')} + + setMinFixabilityScore(value ?? 0)} + aria-label={t('Minimum fixability score')} + size="sm" + /> + + + + + + + + {filteredAndSortedClusters.map((cluster: ClusterSummary) => ( + + ))} + + + )} +
+ ); +} + +// Styled Components +const PageContainer = styled('div')` + padding: ${space(3)} ${space(4)}; + max-width: 1600px; + margin: 0 auto; +`; + +const PageHeader = styled('div')` + margin-bottom: ${space(2)}; +`; + +const CardsGrid = styled('div')` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); + gap: ${space(3)}; +`; + +const CardContainer = styled('div')<{isRemoving: boolean}>` + background: ${p => p.theme.background}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + padding: ${space(3)}; + display: flex; + flex-direction: column; + gap: ${space(2)}; + opacity: ${p => (p.isRemoving ? 0 : 1)}; + transform: ${p => (p.isRemoving ? 'scale(0.95)' : 'scale(1)')}; + transition: + opacity 0.3s ease, + transform 0.3s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + border-color: ${p => p.theme.purple300}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } +`; + +const ClusterTitle = styled(Heading)` + flex: 1; + line-height: 1.3; + color: ${p => p.theme.headingColor}; + display: flex; + flex-direction: column; + gap: ${space(0.5)}; +`; + +const IssueCount = styled('div')` + display: flex; + flex-direction: column; + align-items: center; + padding: ${space(1)} ${space(1.5)}; + background: ${p => p.theme.purple100}; + border-radius: ${p => p.theme.borderRadius}; + min-width: 60px; + flex-shrink: 0; +`; + +const CountNumber = styled('div')` + font-size: 24px; + font-weight: 600; + color: ${p => p.theme.purple400}; + line-height: 1; +`; + +const CountLabel = styled(Text)` + margin-top: ${space(0.25)}; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +`; + +const IssuePreviewContainer = styled(Link)` + display: block; + padding: ${space(1.5)} ${space(2)}; + background: ${p => p.theme.background}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + transition: all 0.15s ease; + + &:hover { + border-color: ${p => p.theme.purple300}; + background: ${p => p.theme.backgroundElevated}; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + transform: translateX(2px); + } +`; + +const CompactIssueWrapper = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(0.75)}; +`; + +const CompactTitle = styled('div')` + font-size: ${p => p.theme.fontSize.md}; + font-weight: ${p => p.theme.fontWeight.bold}; + color: ${p => p.theme.textColor}; + line-height: 1.4; + ${p => p.theme.overflowEllipsis}; + + & em { + font-size: ${p => p.theme.fontSize.sm}; + font-style: normal; + font-weight: ${p => p.theme.fontWeight.normal}; + color: ${p => p.theme.subText}; + } +`; + +const CompactMessage = styled(EventMessage)` + margin: 0; + font-size: ${p => p.theme.fontSize.sm}; + color: ${p => p.theme.subText}; + line-height: 1.4; +`; + +const CompactLocation = styled('div')` + font-size: ${p => p.theme.fontSize.sm}; + color: ${p => p.theme.subText}; + line-height: 1.3; + ${p => p.theme.overflowEllipsis}; +`; + +const CompactExtra = styled('div')` + display: inline-grid; + grid-auto-flow: column dense; + gap: ${space(0.75)}; + justify-content: start; + align-items: center; + color: ${p => p.theme.subText}; + font-size: ${p => p.theme.fontSize.xs}; + line-height: 1.2; + + & > a { + color: ${p => p.theme.subText}; + } +`; + +const CompactSeparator = styled('div')` + height: 10px; + width: 1px; + background-color: ${p => p.theme.innerBorder}; + border-radius: 1px; +`; + +const ShadowlessProjectBadge = styled(ProjectBadge)` + * > img { + box-shadow: none; + } +`; + +const CommentsLink = styled(Link)` + display: inline-grid; + gap: ${space(0.5)}; + align-items: center; + grid-auto-flow: column; + color: ${p => p.theme.textColor}; +`; + +const IconWrapper = styled('span')` + display: inline-flex; + margin-right: ${space(0.5)}; +`; + +const EventCount = styled('span')` + color: ${p => p.theme.textColor}; + font-weight: ${p => p.theme.fontWeight.bold}; +`; + +const JsonTextarea = styled('textarea')` + width: 100%; + min-height: 300px; + padding: ${space(1.5)}; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 13px; + line-height: 1.5; + background: ${p => p.theme.backgroundSecondary}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + color: ${p => p.theme.textColor}; + resize: vertical; + + &:focus { + outline: none; + border-color: ${p => p.theme.purple300}; + box-shadow: 0 0 0 1px ${p => p.theme.purple300}; + } + + &::placeholder { + color: ${p => p.theme.subText}; + } +`; + +const AdvancedFilterContent = styled('div')` + padding: ${space(2)} 0; + display: flex; + flex-direction: column; + gap: ${space(1.5)}; +`; + +export default DynamicGrouping; diff --git a/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx b/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx index 9117caa9302dc1..55ae44c31e76fd 100644 --- a/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx +++ b/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx @@ -17,6 +17,7 @@ export function IssuesSecondaryNav() { const organization = useOrganization(); const sectionRef = useRef(null); const baseUrl = `/organizations/${organization.slug}/issues`; + const hasTopIssuesUI = organization.features.includes('top-issues-ui'); return ( @@ -57,6 +58,16 @@ export function IssuesSecondaryNav() { + {hasTopIssuesUI && ( + + + {t('Top Issues')} + + + )}