index.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import {useCallback, useEffect, useState} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import * as qs from 'query-string';
  6. import {Alert} from 'sentry/components/alert';
  7. import * as Layout from 'sentry/components/layouts/thirds';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import IssuesReplayCountProvider from 'sentry/components/replays/issuesReplayCountProvider';
  11. import {t} from 'sentry/locale';
  12. import GroupingStore, {SimilarItem} from 'sentry/stores/groupingStore';
  13. import {space} from 'sentry/styles/space';
  14. import {Project} from 'sentry/types';
  15. import {useNavigate} from 'sentry/utils/useNavigate';
  16. import usePrevious from 'sentry/utils/usePrevious';
  17. import List from './list';
  18. type RouteParams = {
  19. groupId: string;
  20. orgId: string;
  21. };
  22. type Props = RouteComponentProps<RouteParams, {}> & {
  23. location: Location;
  24. project: Project;
  25. };
  26. type ItemState = {
  27. filtered: SimilarItem[];
  28. pageLinks: string | null;
  29. similar: SimilarItem[];
  30. };
  31. function SimilarStackTrace({params, location, project}: Props) {
  32. const {orgId, groupId} = params;
  33. const [items, setItems] = useState<ItemState>({
  34. similar: [],
  35. filtered: [],
  36. pageLinks: null,
  37. });
  38. const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
  39. const navigate = useNavigate();
  40. const prevLocationSearch = usePrevious(location.search);
  41. const hasSimilarityFeature = project.features.includes('similarity-view');
  42. const fetchData = useCallback(() => {
  43. setStatus('loading');
  44. const reqs: Parameters<typeof GroupingStore.onFetch>[0] = [];
  45. if (hasSimilarityFeature) {
  46. reqs.push({
  47. endpoint: `/issues/${groupId}/similar/?${qs.stringify({
  48. ...location.query,
  49. limit: 50,
  50. })}`,
  51. dataKey: 'similar',
  52. });
  53. }
  54. GroupingStore.onFetch(reqs);
  55. }, [location.query, groupId, hasSimilarityFeature]);
  56. const onGroupingChange = useCallback(
  57. ({
  58. mergedParent,
  59. similarItems: updatedSimilarItems,
  60. filteredSimilarItems: updatedFilteredSimilarItems,
  61. similarLinks: updatedSimilarLinks,
  62. loading,
  63. error,
  64. }) => {
  65. if (updatedSimilarItems) {
  66. setItems({
  67. similar: updatedSimilarItems,
  68. filtered: updatedFilteredSimilarItems,
  69. pageLinks: updatedSimilarLinks,
  70. });
  71. setStatus(error ? 'error' : loading ? 'loading' : 'ready');
  72. return;
  73. }
  74. if (mergedParent && mergedParent !== groupId) {
  75. // Merge success, since we can't specify target, we need to redirect to new parent
  76. navigate(`/organizations/${orgId}/issues/${mergedParent}/similar/`);
  77. }
  78. },
  79. [navigate, groupId, orgId]
  80. );
  81. useEffect(() => {
  82. fetchData();
  83. }, [fetchData]);
  84. useEffect(() => {
  85. if (prevLocationSearch !== location.search) {
  86. fetchData();
  87. }
  88. }, [fetchData, prevLocationSearch, location.search]);
  89. useEffect(() => {
  90. const unsubscribe = GroupingStore.listen(onGroupingChange, undefined);
  91. return () => {
  92. unsubscribe();
  93. };
  94. }, [onGroupingChange]);
  95. const handleMerge = useCallback(() => {
  96. if (!params) {
  97. return;
  98. }
  99. // You need at least 1 similarItem OR filteredSimilarItems to be able to merge,
  100. // so `firstIssue` should always exist from one of those lists.
  101. //
  102. // Similar issues API currently does not return issues across projects,
  103. // so we can assume that the first issues project slug is the project in
  104. // scope
  105. const [firstIssue] = items.similar.length ? items.similar : items.filtered;
  106. GroupingStore.onMerge({
  107. params,
  108. query: location.query,
  109. projectId: firstIssue.issue.project.slug,
  110. });
  111. }, [params, location.query, items]);
  112. const hasSimilarItems =
  113. hasSimilarityFeature &&
  114. (items.similar.length > 0 || items.filtered.length > 0) &&
  115. status === 'ready';
  116. const groupsIds = items.similar.concat(items.filtered).map(({issue}) => issue.id);
  117. return (
  118. <Layout.Body>
  119. <Layout.Main fullWidth>
  120. <Alert type="warning">
  121. {t(
  122. 'This is an experimental feature. Data may not be immediately available while we process merges.'
  123. )}
  124. </Alert>
  125. <HeaderWrapper>
  126. <Title>{t('Issues with a similar stack trace')}</Title>
  127. </HeaderWrapper>
  128. {status === 'loading' && <LoadingIndicator />}
  129. {status === 'error' && (
  130. <LoadingError
  131. message={t('Unable to load similar issues, please try again later')}
  132. onRetry={fetchData}
  133. />
  134. )}
  135. {hasSimilarItems && (
  136. <IssuesReplayCountProvider groupIds={groupsIds}>
  137. <List
  138. items={items.similar}
  139. filteredItems={items.filtered}
  140. onMerge={handleMerge}
  141. orgId={orgId}
  142. project={project}
  143. groupId={groupId}
  144. pageLinks={items.pageLinks}
  145. />
  146. </IssuesReplayCountProvider>
  147. )}
  148. </Layout.Main>
  149. </Layout.Body>
  150. );
  151. }
  152. export default SimilarStackTrace;
  153. const Title = styled('h4')`
  154. margin-bottom: 0;
  155. `;
  156. const HeaderWrapper = styled('div')`
  157. display: flex;
  158. align-items: center;
  159. justify-content: space-between;
  160. margin-bottom: ${space(2)};
  161. `;