Skip to content

Commit 25fda65

Browse files
armenzgscttcpermalwilley
authored
feat(related-issues): Add tab showing issues with the same root cause (#67079)
This PR depends on #66992 which adds the API to related issues. --------- Co-authored-by: Scott Cooper <[email protected]> Co-authored-by: Malachi Willey <[email protected]>
1 parent 9af029c commit 25fda65

File tree

5 files changed

+208
-0
lines changed

5 files changed

+208
-0
lines changed

static/app/routes.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,12 @@ function buildRoutes() {
18151815
make(() => import('sentry/views/issueDetails/groupSimilarIssues'))
18161816
)}
18171817
/>
1818+
<Route
1819+
path={TabPaths[Tab.RELATED_ISSUES]}
1820+
component={hoc(
1821+
make(() => import('sentry/views/issueDetails/groupRelatedIssues'))
1822+
)}
1823+
/>
18181824
<Route
18191825
path={TabPaths[Tab.MERGED]}
18201826
component={hoc(make(() => import('sentry/views/issueDetails/groupMerged')))}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {RouterFixture} from 'sentry-fixture/routerFixture';
3+
4+
import {render, screen} from 'sentry-test/reactTestingLibrary';
5+
6+
import {GroupRelatedIssues} from 'sentry/views/issueDetails/groupRelatedIssues';
7+
8+
describe('Related Issues View', function () {
9+
let relatedIssuesMock: jest.Mock;
10+
let issuesInfoMock: jest.Mock;
11+
const router = RouterFixture();
12+
13+
const organization = OrganizationFixture();
14+
const orgSlug = organization.slug;
15+
const groupId = '12345678';
16+
const group1 = '15';
17+
const group2 = '20';
18+
// query=issue.id:[15,20] -> query=issue.id%3A%5B15%2C20%5D
19+
const orgIssuesEndpoint = `/organizations/${orgSlug}/issues/?query=issue.id%3A%5B${group1}%2C${group2}%5D`;
20+
21+
const params = {groupId: groupId};
22+
const errorType = 'RuntimeError';
23+
24+
beforeEach(function () {
25+
// GroupList calls this but we don't need it for this test
26+
MockApiClient.addMockResponse({
27+
url: `/organizations/${orgSlug}/users/`,
28+
body: {},
29+
});
30+
relatedIssuesMock = MockApiClient.addMockResponse({
31+
url: `/issues/${groupId}/related-issues/`,
32+
body: {same_root_cause: [group1, group2]},
33+
});
34+
issuesInfoMock = MockApiClient.addMockResponse({
35+
url: orgIssuesEndpoint,
36+
body: [
37+
{
38+
id: group1,
39+
shortId: `EARTH-${group1}`,
40+
project: {id: '3', name: 'Earth', slug: 'earth', platform: null},
41+
type: 'error',
42+
metadata: {
43+
type: errorType,
44+
},
45+
issueCategory: 'error',
46+
lastSeen: '2024-03-15T20:15:30Z',
47+
},
48+
{
49+
id: group2,
50+
shortId: `EARTH-${group2}`,
51+
project: {id: '3', name: 'Earth', slug: 'earth', platform: null},
52+
type: 'error',
53+
metadata: {
54+
type: errorType,
55+
},
56+
issueCategory: 'error',
57+
lastSeen: '2024-03-16T20:15:30Z',
58+
},
59+
],
60+
});
61+
});
62+
63+
afterEach(() => {
64+
MockApiClient.clearMockResponses();
65+
jest.clearAllMocks();
66+
});
67+
68+
it('renders with mocked data', async function () {
69+
render(
70+
<GroupRelatedIssues
71+
params={params}
72+
location={router.location}
73+
router={router}
74+
routeParams={router.params}
75+
routes={router.routes}
76+
route={{}}
77+
/>
78+
);
79+
80+
// Wait for the issues showing up on the table
81+
expect(await screen.findByText(`EARTH-${group1}`)).toBeInTheDocument();
82+
expect(await screen.findByText(`EARTH-${group2}`)).toBeInTheDocument();
83+
84+
expect(relatedIssuesMock).toHaveBeenCalled();
85+
expect(issuesInfoMock).toHaveBeenCalled();
86+
});
87+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// XXX: A lot of the UI for this file will be changed once we use IssueListActions
2+
// We're using GroupList to help us iterate quickly
3+
import type {RouteComponentProps} from 'react-router';
4+
import styled from '@emotion/styled';
5+
6+
import Feature from 'sentry/components/acl/feature';
7+
import GroupList from 'sentry/components/issues/groupList';
8+
import * as Layout from 'sentry/components/layouts/thirds';
9+
import LoadingError from 'sentry/components/loadingError';
10+
import LoadingIndicator from 'sentry/components/loadingIndicator';
11+
import {t} from 'sentry/locale';
12+
import {space} from 'sentry/styles/space';
13+
import {useApiQuery} from 'sentry/utils/queryClient';
14+
import useOrganization from 'sentry/utils/useOrganization';
15+
16+
type RouteParams = {
17+
groupId: string;
18+
};
19+
20+
type Props = RouteComponentProps<RouteParams, {}>;
21+
22+
type RelatedIssuesResponse = {
23+
same_root_cause: number[];
24+
};
25+
26+
function GroupRelatedIssues({params}: Props) {
27+
const {groupId} = params;
28+
29+
const organization = useOrganization();
30+
const orgSlug = organization.slug;
31+
32+
// Fetch the list of related issues
33+
const {
34+
isLoading,
35+
isError,
36+
data: relatedIssues,
37+
refetch,
38+
} = useApiQuery<RelatedIssuesResponse>([`/issues/${groupId}/related-issues/`], {
39+
staleTime: 0,
40+
});
41+
42+
// If the group we're looking related issues for shows up in the table,
43+
// it will trigger a bug in getGroupReprocessingStatus because activites would be empty
44+
const groups = relatedIssues?.same_root_cause
45+
?.filter(id => id.toString() !== groupId)
46+
?.join(',');
47+
48+
return (
49+
<Layout.Body>
50+
<Layout.Main fullWidth>
51+
<HeaderWrapper>
52+
<Title>{t('Related Issues')}</Title>
53+
<small>
54+
{t(
55+
'Related Issues are issues that may have the same root cause and can be acted on together.'
56+
)}
57+
</small>
58+
</HeaderWrapper>
59+
{isLoading ? (
60+
<LoadingIndicator />
61+
) : isError ? (
62+
<LoadingError
63+
message={t('Unable to load related issues, please try again later')}
64+
onRetry={refetch}
65+
/>
66+
) : groups ? (
67+
<GroupList
68+
endpointPath={`/organizations/${orgSlug}/issues/`}
69+
orgSlug={orgSlug}
70+
queryParams={{query: `issue.id:[${groups}]`}}
71+
query=""
72+
source="related-issues-tab"
73+
renderEmptyMessage={() => <Title>No related issues</Title>}
74+
renderErrorMessage={() => <Title>Error loading related issues</Title>}
75+
/>
76+
) : (
77+
<b>No related issues found!</b>
78+
)}
79+
</Layout.Main>
80+
</Layout.Body>
81+
);
82+
}
83+
84+
function GroupRelatedIssuesWrapper(props: Props) {
85+
return (
86+
<Feature features={['related-issues']}>
87+
<GroupRelatedIssues {...props} />
88+
</Feature>
89+
);
90+
}
91+
92+
// Export the component without feature flag controls
93+
export {GroupRelatedIssues};
94+
export default GroupRelatedIssuesWrapper;
95+
96+
const Title = styled('h4')`
97+
margin-bottom: ${space(0.75)};
98+
`;
99+
100+
const HeaderWrapper = styled('div')`
101+
margin-bottom: ${space(2)};
102+
103+
small {
104+
color: ${p => p.theme.subText};
105+
}
106+
`;

static/app/views/issueDetails/header.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ function GroupHeaderTabs({
139139
>
140140
{t('Merged Issues')}
141141
</TabList.Item>
142+
<TabList.Item
143+
key={Tab.RELATED_ISSUES}
144+
hidden={!organizationFeatures.has('related-issues')}
145+
to={`${baseUrl}related/${location.search}`}
146+
>
147+
{t('Related Issues')}
148+
</TabList.Item>
142149
<TabList.Item
143150
key={Tab.SIMILAR_ISSUES}
144151
hidden={!hasSimilarView || !issueTypeConfig.similarIssues.enabled}

static/app/views/issueDetails/types.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum Tab {
77
EVENTS = 'events',
88
MERGED = 'merged',
99
SIMILAR_ISSUES = 'similar-issues',
10+
RELATED_ISSUES = 'related-issues',
1011
REPLAYS = 'Replays',
1112
}
1213

@@ -19,5 +20,6 @@ export const TabPaths: Record<Tab, string> = {
1920
[Tab.EVENTS]: 'events/',
2021
[Tab.MERGED]: 'merged/',
2122
[Tab.SIMILAR_ISSUES]: 'similar/',
23+
[Tab.RELATED_ISSUES]: 'related/',
2224
[Tab.REPLAYS]: 'replays/',
2325
};

0 commit comments

Comments
 (0)