diff --git a/static/app/views/issueList/pages/dynamicGrouping.tsx b/static/app/views/issueList/pages/dynamicGrouping.tsx index 57ba9ccfe20d19..f9b9f2722dac34 100644 --- a/static/app/views/issueList/pages/dynamicGrouping.tsx +++ b/static/app/views/issueList/pages/dynamicGrouping.tsx @@ -1,4 +1,4 @@ -import {Fragment, useMemo, useState} from 'react'; +import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; import {Container, Flex} from '@sentry/scraps/layout'; @@ -14,42 +14,37 @@ 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 LoadingIndicator from 'sentry/components/loadingIndicator'; 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'; interface AssignedEntity { - email: string | null; + email: string | null; // unused id: string; - name: string; + name: string; // unused type: string; } interface ClusterSummary { assignedTo: AssignedEntity[]; - cluster_avg_similarity: number | null; + cluster_avg_similarity: number | null; // unused cluster_id: number; - cluster_min_similarity: number | null; - cluster_size: number | null; + cluster_min_similarity: number | null; // unused + cluster_size: number | null; // unused description: string; fixability_score: number | null; group_ids: number[]; - issue_titles: string[]; - project_ids: number[]; - tags: string[]; + issue_titles: string[]; // unused + project_ids: number[]; // unused + tags: string[]; // unused title: string; } @@ -57,89 +52,57 @@ interface TopIssuesResponse { data: ClusterSummary[]; } -// 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)} + + {tn('%s event', '%s events', group.count)} + ) : null, - group.lifetime || group.firstSeen || group.lastSeen ? ( - - ) : null, - group.numComments > 0 ? ( - - - {group.numComments} - + group.firstSeen || group.lastSeen ? ( + ) : null, - showReplayCount ? : null, - ].filter(defined); + ].filter(Boolean); return ( - - - {group.isBookmarked && ( - - - - )} + + - - + - {subtitle && {subtitle}} + {subtitle && ( + + {subtitle} + + )} {items.length > 0 && ( - + {items.map((item, i) => ( {item} - {i < items.length - 1 ? : null} + {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( @@ -153,18 +116,12 @@ function ClusterIssues({groupIds}: {groupIds: number[]}) { }, ], { - staleTime: 60000, // Cache for 1 minute + staleTime: 60000, } ); if (isPending) { - return ( - - {[0, 1, 2].map(i => ( - - ))} - - ); + return ; } if (!groups || groups.length === 0) { @@ -174,72 +131,70 @@ function ClusterIssues({groupIds}: {groupIds: number[]}) { return ( {groups.map(group => ( - - + ))} ); } -// Individual cluster card function ClusterCard({ cluster, onRemove, - isRemoving, }: { cluster: ClusterSummary; - isRemoving: boolean; onRemove: (clusterId: number) => void; }) { const organization = useOrganization(); + const issueCount = cluster.group_ids.length; return ( - + - - - - - {cluster.title} - - - - {cluster.description} - {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.title} + + + + + {cluster.description} + + {cluster.fixability_score !== null && ( + + {t('%s%% confidence', Math.round(cluster.fixability_score * 100))} + + )} + + + + {issueCount} + + {tn('issue', 'issues', issueCount)} + + - + {cluster.group_ids.length > 3 && ( {t('+ %s more similar issues', cluster.group_ids.length - 3)} @@ -250,9 +205,6 @@ function ClusterCard({ - @@ -269,98 +221,42 @@ function DynamicGrouping() { const {teams} = useUserTeams(); const [filterByAssignedToMe, setFilterByAssignedToMe] = useState(true); const [minFixabilityScore, setMinFixabilityScore] = useState(50); - const [removingClusterId, setRemovingClusterId] = useState(null); const [removedClusterIds, setRemovedClusterIds] = useState>(new Set()); // Fetch cluster data from API const {data: topIssuesResponse, isPending} = useApiQuery( [`/organizations/${organization.slug}/top-issues/`], { - staleTime: 60000, // Cache for 1 minute + staleTime: 60000, } ); - const clusterData = useMemo( - () => topIssuesResponse?.data ?? [], - [topIssuesResponse?.data] - ); + const clusterData = topIssuesResponse?.data ?? []; const handleRemoveCluster = (clusterId: number) => { - // Start animation - setRemovingClusterId(clusterId); - - // Wait for animation to complete before removing - setTimeout(() => { - const updatedRemovedIds = new Set(removedClusterIds); - updatedRemovedIds.add(clusterId); - setRemovedClusterIds(updatedRemovedIds); - setRemovingClusterId(null); - }, 300); // Match the animation duration + setRemovedClusterIds(prev => new Set([...prev, clusterId])); }; - // 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); + const filteredAndSortedClusters = clusterData + .filter(cluster => { + if (removedClusterIds.has(cluster.cluster_id)) return false; - return (cluster: ClusterSummary) => { - // If no assignedTo data, include the cluster - if (!cluster.assignedTo || cluster.assignedTo.length === 0) { - return false; + const fixabilityScore = (cluster.fixability_score ?? 0) * 100; + if (fixabilityScore < minFixabilityScore) return false; + + if (filterByAssignedToMe) { + if (!cluster.assignedTo?.length) return false; + return cluster.assignedTo.some( + entity => + (entity.type === 'user' && entity.id === user.id) || + (entity.type === 'team' && teams.some(team => team.id === entity.id)) + ); } + return true; + }) + .sort((a, b) => (b.fixability_score ?? 0) - (a.fixability_score ?? 0)); - // 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 out removed clusters - if (removedClusterIds.has(cluster.cluster_id)) { - return false; - } - - // 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, - removedClusterIds, - filterByAssignedToMe, - minFixabilityScore, - isClusterAssignedToMe, - ]); - - const totalIssues = useMemo(() => { - return filteredAndSortedClusters.reduce( - (sum: number, c: ClusterSummary) => sum + (c.cluster_size ?? c.group_ids.length), - 0 - ); - }, [filteredAndSortedClusters]); + const totalIssues = filteredAndSortedClusters.flatMap(c => c.group_ids).length; const hasTopIssuesUI = organization.features.includes('top-issues-ui'); if (!hasTopIssuesUI) { @@ -368,7 +264,7 @@ function DynamicGrouping() { } return ( - + - - {t('Top Issues')} - + + {t('Top Issues')} + {isPending ? ( - - {[0, 1, 2].map(i => ( - - ))} - + ) : ( - - - {tn( - 'Viewing %s issue in %s cluster', - 'Viewing %s issues across %s clusters', - totalIssues, - filteredAndSortedClusters.length - )} - - + + {tn( + 'Viewing %s issue in %s cluster', + 'Viewing %s issues across %s clusters', + totalIssues, + filteredAndSortedClusters.length + )} + @@ -418,7 +309,7 @@ function DynamicGrouping() { - + - + {filteredAndSortedClusters.length === 0 ? ( - + {t('No clusters match the current filters')} ) : ( - {filteredAndSortedClusters.map((cluster: ClusterSummary) => ( + {filteredAndSortedClusters.map(cluster => ( ))} )} )} - + ); } -// 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)}; -`; - +// Grid layout for cards - needs CSS grid const CardsGrid = styled('div')` display: grid; grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); gap: ${space(3)}; `; -const CardContainer = styled('div')<{isRemoving: boolean}>` +// Card with hover effect - needs custom transition/hover styles +const CardContainer = styled('div')` 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; @@ -511,57 +387,8 @@ const CardContainer = styled('div')<{isRemoving: boolean}>` } `; -const SummaryText = styled(Text)` - line-height: 1.6; - display: block; - margin-bottom: ${space(1.5)}; -`; - -const ConfidenceText = styled(Text)` - display: block; - margin-top: ${space(1.5)}; -`; - -const TitleContainer = styled(Flex)` - flex: 1; - min-width: 0; -`; - -const StyledDisclosure = styled(Disclosure)` - /* Target the button inside Disclosure.Title */ - button { - width: 100%; - display: flex !important; - text-align: left !important; - white-space: normal; - word-break: break-word; - padding: 0; - margin: 0; - justify-content: flex-start !important; - align-items: flex-start !important; - } - - /* Ensure inner content is left-aligned */ - button > *, - button span { - text-align: left !important; - justify-content: flex-start !important; - } - - /* Adjust content padding */ - & > div:last-child { - padding-top: ${space(1)}; - } -`; - -const TitleHeading = styled(Heading)` - word-break: break-word; - overflow-wrap: break-word; - white-space: normal; - text-align: left; -`; - -const IssueCount = styled('div')` +// Issue count badge with custom background color +const IssueCountBadge = styled('div')` display: flex; flex-direction: column; align-items: center; @@ -571,54 +398,41 @@ const IssueCount = styled('div')` flex-shrink: 0; `; -const CountNumber = styled('div')` +const IssueCountNumber = 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; - display: inline-block; - width: 6ch; - text-align: center; -`; - -const IssuePreviewContainer = styled(Link)` +// Issue preview link with hover transform effect +const IssuePreviewLink = 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; + transition: + border-color 0.15s ease, + background 0.15s ease, + transform 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')` +// Issue title with ellipsis and nested em styling for EventOrGroupTitle +const IssueTitle = 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; - text-align: left; ${p => p.theme.overflowEllipsis}; - & em { + em { font-size: ${p => p.theme.fontSize.sm}; font-style: normal; font-weight: ${p => p.theme.fontWeight.normal}; @@ -626,71 +440,18 @@ const CompactTitle = styled('div')` } `; -const CompactMessage = styled(EventMessage)` +// EventMessage override for compact display +const IssueMessage = 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')` +// Meta separator line +const MetaSeparator = 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 AdvancedFilterContent = styled('div')` - padding: ${space(2)} 0; - display: flex; - flex-direction: column; - gap: ${space(1.5)}; `; export default DynamicGrouping;