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;