overviewFc.tsx 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import type {Location} from 'history';
  5. import Cookies from 'js-cookie';
  6. import isEqual from 'lodash/isEqual';
  7. import mapValues from 'lodash/mapValues';
  8. import omit from 'lodash/omit';
  9. import pickBy from 'lodash/pickBy';
  10. import moment from 'moment-timezone';
  11. import * as qs from 'query-string';
  12. import {addMessage} from 'sentry/actionCreators/indicator';
  13. import {fetchOrgMembers, indexMembersByProject} from 'sentry/actionCreators/members';
  14. import type {Request} from 'sentry/api';
  15. import ErrorBoundary from 'sentry/components/errorBoundary';
  16. import * as Layout from 'sentry/components/layouts/thirds';
  17. import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
  18. import type {CursorHandler} from 'sentry/components/pagination';
  19. import QueryCount from 'sentry/components/queryCount';
  20. import {DEFAULT_QUERY, DEFAULT_STATS_PERIOD} from 'sentry/constants';
  21. import {t, tct} from 'sentry/locale';
  22. import GroupStore from 'sentry/stores/groupStore';
  23. import IssueListCacheStore from 'sentry/stores/IssueListCacheStore';
  24. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  25. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  26. import {space} from 'sentry/styles/space';
  27. import type {PageFilters} from 'sentry/types/core';
  28. import type {BaseGroup, Group, PriorityLevel, SavedSearch} from 'sentry/types/group';
  29. import {GroupStatus, IssueCategory} from 'sentry/types/group';
  30. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  31. import type {Organization} from 'sentry/types/organization';
  32. import {defined} from 'sentry/utils';
  33. import {trackAnalytics} from 'sentry/utils/analytics';
  34. import {browserHistory} from 'sentry/utils/browserHistory';
  35. import CursorPoller from 'sentry/utils/cursorPoller';
  36. import {getUtcDateString} from 'sentry/utils/dates';
  37. import getCurrentSentryReactRootSpan from 'sentry/utils/getCurrentSentryReactRootSpan';
  38. import parseApiError from 'sentry/utils/parseApiError';
  39. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  40. import {makeIssuesINPObserver} from 'sentry/utils/performanceForSentry';
  41. import {decodeScalar} from 'sentry/utils/queryString';
  42. import useDisableRouteAnalytics from 'sentry/utils/routeAnalytics/useDisableRouteAnalytics';
  43. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  44. import type {WithRouteAnalyticsProps} from 'sentry/utils/routeAnalytics/withRouteAnalytics';
  45. import withRouteAnalytics from 'sentry/utils/routeAnalytics/withRouteAnalytics';
  46. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  47. import useApi from 'sentry/utils/useApi';
  48. import usePrevious from 'sentry/utils/usePrevious';
  49. import withOrganization from 'sentry/utils/withOrganization';
  50. import withPageFilters from 'sentry/utils/withPageFilters';
  51. import withSavedSearches from 'sentry/utils/withSavedSearches';
  52. import IssueListTable from 'sentry/views/issueList/issueListTable';
  53. import {IssuesDataConsentBanner} from 'sentry/views/issueList/issuesDataConsentBanner';
  54. import IssueViewsIssueListHeader from 'sentry/views/issueList/issueViewsHeader';
  55. import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches';
  56. import type {IssueUpdateData} from 'sentry/views/issueList/types';
  57. import {NewTabContextProvider} from 'sentry/views/issueList/utils/newTabContext';
  58. import {parseIssuePrioritySearch} from 'sentry/views/issueList/utils/parseIssuePrioritySearch';
  59. import IssueListFilters from './filters';
  60. import IssueListHeader from './header';
  61. import type {QueryCounts} from './utils';
  62. import {
  63. DEFAULT_ISSUE_STREAM_SORT,
  64. FOR_REVIEW_QUERIES,
  65. getTabs,
  66. getTabsWithCounts,
  67. isForReviewQuery,
  68. IssueSortOptions,
  69. Query,
  70. TAB_MAX_COUNT,
  71. } from './utils';
  72. const MAX_ITEMS = 25;
  73. // the default period for the graph in each issue row
  74. const DEFAULT_GRAPH_STATS_PERIOD = '24h';
  75. // the allowed period choices for graph in each issue row
  76. const DYNAMIC_COUNTS_STATS_PERIODS = new Set(['14d', '24h', 'auto']);
  77. const MAX_ISSUES_COUNT = 100;
  78. type Params = {
  79. orgId: string;
  80. };
  81. type Props = {
  82. location: Location;
  83. organization: Organization;
  84. params: Params;
  85. savedSearch: SavedSearch;
  86. savedSearchLoading: boolean;
  87. savedSearches: SavedSearch[];
  88. selectedSearchId: string;
  89. selection: PageFilters;
  90. } & RouteComponentProps<{}, {searchId?: string}> &
  91. WithRouteAnalyticsProps;
  92. interface EndpointParams extends Partial<PageFilters['datetime']> {
  93. environment: string[];
  94. project: number[];
  95. cursor?: string;
  96. groupStatsPeriod?: string | null;
  97. page?: number | string;
  98. query?: string;
  99. sort?: string;
  100. statsPeriod?: string | null;
  101. useGroupSnubaDataset?: boolean;
  102. }
  103. type CountsEndpointParams = Omit<EndpointParams, 'cursor' | 'page' | 'query'> & {
  104. query: string[];
  105. };
  106. type StatEndpointParams = Omit<EndpointParams, 'cursor' | 'page'> & {
  107. groups: string[];
  108. expand?: string | string[];
  109. };
  110. function useIssuesINPObserver() {
  111. const _performanceObserver = useRef<PerformanceObserver | undefined>(undefined);
  112. useEffect(() => {
  113. _performanceObserver.current = makeIssuesINPObserver();
  114. return () => {
  115. if (_performanceObserver.current) {
  116. _performanceObserver.current.disconnect();
  117. }
  118. };
  119. }, []);
  120. }
  121. function IssueListOverviewFc({
  122. organization,
  123. location,
  124. router,
  125. savedSearch,
  126. savedSearches,
  127. savedSearchLoading,
  128. selection,
  129. selectedSearchId,
  130. }: Props) {
  131. const api = useApi();
  132. const realtimeActiveCookie = Cookies.get('realtimeActive');
  133. const [realtimeActive, setRealtimeActive] = useState(
  134. typeof realtimeActiveCookie === 'undefined' ? false : realtimeActiveCookie === 'true'
  135. );
  136. const [groupIds, setGroupIds] = useState<string[]>([]);
  137. const [pageLinks, setPageLinks] = useState('');
  138. const [queryCount, setQueryCount] = useState(0);
  139. const [queryCounts, setQueryCounts] = useState<QueryCounts>({});
  140. const [queryMaxCount, setQueryMaxCount] = useState(0);
  141. const [error, setError] = useState<string | null>(null);
  142. const [issuesLoading, setIssuesLoading] = useState(true);
  143. const [memberList, setMemberList] = useState<ReturnType<typeof indexMembersByProject>>(
  144. {}
  145. );
  146. const undoRef = useRef(false);
  147. const lastRequestRef = useRef<Request | null>(null);
  148. const lastStatsRequestRef = useRef<Request | null>(null);
  149. const lastFetchCountsRequestRef = useRef<Request | null>(null);
  150. const pollerRef = useRef<CursorPoller | undefined>(undefined);
  151. const actionTakenRef = useRef(false);
  152. const groups = useLegacyStore(GroupStore);
  153. useEffect(() => {
  154. const storeGroupIds = groups.map(group => group.id).slice(0, MAX_ISSUES_COUNT);
  155. if (!isEqual(groupIds, storeGroupIds)) {
  156. setGroupIds(storeGroupIds);
  157. }
  158. // eslint-disable-next-line react-hooks/exhaustive-deps
  159. }, [groups]);
  160. useIssuesINPObserver();
  161. const onRealtimePoll = useCallback(
  162. (data: any, {queryCount: newQueryCount}: {queryCount: number}) => {
  163. // Note: We do not update state with cursors from polling,
  164. // `CursorPoller` updates itself with new cursors
  165. GroupStore.addToFront(data);
  166. setQueryCount(newQueryCount);
  167. },
  168. []
  169. );
  170. useEffect(() => {
  171. pollerRef.current = new CursorPoller({
  172. linkPreviousHref: parseLinkHeader(pageLinks)?.previous!?.href,
  173. success: onRealtimePoll,
  174. });
  175. }, [onRealtimePoll, pageLinks]);
  176. const getQueryFromSavedSearchOrLocation = useCallback(
  177. (props: Pick<Props, 'savedSearch' | 'location'>): string => {
  178. if (
  179. !organization.features.includes('issue-stream-custom-views') &&
  180. props.savedSearch
  181. ) {
  182. return props.savedSearch.query;
  183. }
  184. const {query} = props.location.query;
  185. if (query !== undefined) {
  186. return decodeScalar(query, '');
  187. }
  188. return DEFAULT_QUERY;
  189. },
  190. [organization.features]
  191. );
  192. const getSortFromSavedSearchOrLocation = useCallback(
  193. (props: Pick<Props, 'savedSearch' | 'location'>): string => {
  194. if (!props.location.query.sort && props.savedSearch?.id) {
  195. return props.savedSearch.sort;
  196. }
  197. if (props.location.query.sort) {
  198. return props.location.query.sort as string;
  199. }
  200. return DEFAULT_ISSUE_STREAM_SORT;
  201. },
  202. []
  203. );
  204. const query = useMemo((): string => {
  205. return getQueryFromSavedSearchOrLocation({
  206. savedSearch,
  207. location,
  208. });
  209. }, [getQueryFromSavedSearchOrLocation, savedSearch, location]);
  210. const sort = useMemo((): string => {
  211. return getSortFromSavedSearchOrLocation({
  212. savedSearch,
  213. location,
  214. });
  215. }, [getSortFromSavedSearchOrLocation, savedSearch, location]);
  216. const getGroupStatsPeriod = useCallback((): string => {
  217. let currentPeriod: string;
  218. if (typeof location.query?.groupStatsPeriod === 'string') {
  219. currentPeriod = location.query.groupStatsPeriod;
  220. } else {
  221. currentPeriod = DEFAULT_GRAPH_STATS_PERIOD;
  222. }
  223. return DYNAMIC_COUNTS_STATS_PERIODS.has(currentPeriod)
  224. ? currentPeriod
  225. : DEFAULT_GRAPH_STATS_PERIOD;
  226. }, [location]);
  227. const getEndpointParams = useCallback((): EndpointParams => {
  228. const params: EndpointParams = {
  229. project: selection.projects,
  230. environment: selection.environments,
  231. query,
  232. ...selection.datetime,
  233. };
  234. if (selection.datetime.period) {
  235. delete params.period;
  236. params.statsPeriod = selection.datetime.period;
  237. }
  238. if (params.end) {
  239. params.end = getUtcDateString(params.end);
  240. }
  241. if (params.start) {
  242. params.start = getUtcDateString(params.start);
  243. }
  244. if (sort !== DEFAULT_ISSUE_STREAM_SORT) {
  245. params.sort = sort;
  246. }
  247. const groupStatsPeriod = getGroupStatsPeriod();
  248. if (groupStatsPeriod !== DEFAULT_GRAPH_STATS_PERIOD) {
  249. params.groupStatsPeriod = groupStatsPeriod;
  250. }
  251. if (location.query.useGroupSnubaDataset) {
  252. params.useGroupSnubaDataset = true;
  253. }
  254. // only include defined values.
  255. return pickBy(params, v => defined(v)) as EndpointParams;
  256. }, [selection, location, query, sort, getGroupStatsPeriod]);
  257. const requestParams = useMemo(() => {
  258. // Used for Issue Stream Performance project, enabled means we are doing saved search look up in the backend
  259. const savedSearchLookupEnabled = 0;
  260. const savedSearchLookupDisabled = 1;
  261. const params: any = {
  262. ...getEndpointParams(),
  263. limit: MAX_ITEMS,
  264. shortIdLookup: 1,
  265. savedSearch: savedSearchLoading
  266. ? savedSearchLookupEnabled
  267. : savedSearchLookupDisabled,
  268. };
  269. if (selectedSearchId) {
  270. params.searchId = selectedSearchId;
  271. }
  272. if (savedSearchLoading && !defined(location.query.query)) {
  273. delete params.query;
  274. }
  275. const currentQuery = location.query || {};
  276. if ('cursor' in currentQuery) {
  277. params.cursor = currentQuery.cursor;
  278. }
  279. // If no stats period values are set, use default
  280. if (!params.statsPeriod && !params.start) {
  281. params.statsPeriod = DEFAULT_STATS_PERIOD;
  282. }
  283. params.expand = ['owners', 'inbox'];
  284. params.collapse = ['stats', 'unhandled'];
  285. return params;
  286. }, [getEndpointParams, location.query, savedSearchLoading, selectedSearchId]);
  287. const loadFromCache = useCallback((): boolean => {
  288. const cache = IssueListCacheStore.getFromCache(requestParams);
  289. if (!cache) {
  290. return false;
  291. }
  292. setIssuesLoading(false);
  293. setQueryCount(cache.queryCount);
  294. setQueryMaxCount(cache.queryMaxCount);
  295. setPageLinks(cache.pageLinks);
  296. // Handle this in the next tick to avoid being overwritten by GroupStore.reset
  297. // Group details clears the GroupStore at the same time this component mounts
  298. setTimeout(() => {
  299. GroupStore.add(cache.groups);
  300. // Clear cache after loading
  301. IssueListCacheStore.reset();
  302. }, 0);
  303. return true;
  304. }, [requestParams]);
  305. const resumePolling = useCallback(() => {
  306. if (!pageLinks) {
  307. return;
  308. }
  309. // Only resume polling if we're on the first page of results
  310. const links = parseLinkHeader(pageLinks);
  311. if (links && !links.previous!.results && realtimeActive) {
  312. pollerRef.current?.setEndpoint(links?.previous!.href);
  313. pollerRef.current?.enable();
  314. }
  315. }, [pageLinks, realtimeActive]);
  316. const trackTabViewed = useCallback(
  317. (newGroupIds: string[], data: Group[], numHits: number | null) => {
  318. const page = location.query.page;
  319. const endpointParams = getEndpointParams();
  320. const tabQueriesWithCounts = getTabsWithCounts();
  321. const currentTabQuery = tabQueriesWithCounts.includes(endpointParams.query as Query)
  322. ? endpointParams.query
  323. : null;
  324. const tab = getTabs().find(([tabQuery]) => currentTabQuery === tabQuery)?.[1];
  325. const numPerfIssues = newGroupIds.filter(
  326. groupId => GroupStore.get(groupId)?.issueCategory === IssueCategory.PERFORMANCE
  327. ).length;
  328. // First and last seen are only available after the group has fetched stats
  329. // Number of issues shown whose first seen is more than 30 days ago
  330. const numOldIssues = data.filter((group: BaseGroup) =>
  331. moment(new Date(group.firstSeen)).isBefore(moment().subtract(30, 'd'))
  332. ).length;
  333. // number of issues shown whose first seen is less than 7 days
  334. const numNewIssues = data.filter((group: BaseGroup) =>
  335. moment(new Date(group.firstSeen)).isAfter(moment().subtract(7, 'd'))
  336. ).length;
  337. trackAnalytics('issues_tab.viewed', {
  338. organization,
  339. tab: tab?.analyticsName,
  340. page: page ? parseInt(page, 10) : 0,
  341. query,
  342. num_perf_issues: numPerfIssues,
  343. num_old_issues: numOldIssues,
  344. num_new_issues: numNewIssues,
  345. num_issues: data.length,
  346. total_issues_count: numHits,
  347. issue_views_enabled: organization.features.includes('issue-stream-custom-views'),
  348. sort,
  349. });
  350. },
  351. [organization, location, getEndpointParams, query, sort]
  352. );
  353. const fetchCounts = useCallback(
  354. (currentQueryCount: number, fetchAllCounts: boolean) => {
  355. let newQueryCounts: QueryCounts = {...queryCounts};
  356. const endpointParams = getEndpointParams();
  357. const tabQueriesWithCounts = getTabsWithCounts();
  358. const currentTabQuery = tabQueriesWithCounts.includes(endpointParams.query as Query)
  359. ? endpointParams.query
  360. : null;
  361. // Update the count based on the exact number of issues, these shown as is
  362. if (currentTabQuery) {
  363. newQueryCounts[currentTabQuery] = {
  364. count: currentQueryCount,
  365. hasMore: false,
  366. };
  367. }
  368. setQueryCounts(newQueryCounts);
  369. // If all tabs' counts are fetched, skip and only set
  370. if (
  371. fetchAllCounts ||
  372. !tabQueriesWithCounts.every(tabQuery => queryCounts[tabQuery] !== undefined)
  373. ) {
  374. const countsRequestParams: CountsEndpointParams = {
  375. ...omit(endpointParams, 'query'),
  376. // fetch the counts for the tabs whose counts haven't been fetched yet
  377. query: tabQueriesWithCounts.filter(_query => _query !== currentTabQuery),
  378. };
  379. // If no stats period values are set, use default
  380. if (!countsRequestParams.statsPeriod && !countsRequestParams.start) {
  381. countsRequestParams.statsPeriod = DEFAULT_STATS_PERIOD;
  382. }
  383. lastFetchCountsRequestRef.current = api.request(
  384. `/organizations/${organization.slug}/issues-count/`,
  385. {
  386. method: 'GET',
  387. data: qs.stringify(countsRequestParams),
  388. success: data => {
  389. if (!data) {
  390. return;
  391. }
  392. // Counts coming from the counts endpoint is limited to 100, for >= 100 we display 99+
  393. newQueryCounts = {
  394. ...queryCounts,
  395. ...mapValues(data, (count: number) => ({
  396. count,
  397. hasMore: count > TAB_MAX_COUNT,
  398. })),
  399. };
  400. },
  401. error: () => {
  402. setQueryCounts({});
  403. },
  404. complete: () => {
  405. lastFetchCountsRequestRef.current = null;
  406. setQueryCounts(newQueryCounts);
  407. },
  408. }
  409. );
  410. }
  411. },
  412. [api, getEndpointParams, organization.slug, queryCounts]
  413. );
  414. const fetchStats = useCallback(
  415. (newGroupIds: string[]) => {
  416. // If we have no groups to fetch, just skip stats
  417. if (!newGroupIds.length) {
  418. return;
  419. }
  420. const statsRequestParams: StatEndpointParams = {
  421. ...getEndpointParams(),
  422. groups: newGroupIds,
  423. };
  424. // If no stats period values are set, use default
  425. if (!statsRequestParams.statsPeriod && !statsRequestParams.start) {
  426. statsRequestParams.statsPeriod = DEFAULT_STATS_PERIOD;
  427. }
  428. lastStatsRequestRef.current = api.request(
  429. `/organizations/${organization.slug}/issues-stats/`,
  430. {
  431. method: 'GET',
  432. data: qs.stringify(statsRequestParams),
  433. success: data => {
  434. if (!data) {
  435. return;
  436. }
  437. GroupStore.onPopulateStats(newGroupIds, data);
  438. trackTabViewed(newGroupIds, data, queryCount);
  439. },
  440. error: err => {
  441. setError(parseApiError(err));
  442. },
  443. complete: () => {
  444. lastStatsRequestRef.current = null;
  445. // End navigation transaction to prevent additional page requests from impacting page metrics.
  446. // Other transactions include stacktrace preview request
  447. const currentSpan = Sentry.getActiveSpan();
  448. const rootSpan = currentSpan ? Sentry.getRootSpan(currentSpan) : undefined;
  449. if (rootSpan && Sentry.spanToJSON(rootSpan).op === 'navigation') {
  450. rootSpan.end();
  451. }
  452. },
  453. }
  454. );
  455. },
  456. [getEndpointParams, api, organization.slug, trackTabViewed, queryCount]
  457. );
  458. const fetchData = useCallback(
  459. (fetchAllCounts = false) => {
  460. if (realtimeActive || (!actionTakenRef.current && !undoRef.current)) {
  461. GroupStore.loadInitialData([]);
  462. setIssuesLoading(true);
  463. setQueryCount(0);
  464. setError(null);
  465. }
  466. const span = getCurrentSentryReactRootSpan();
  467. span?.setAttribute('query.sort', sort);
  468. setError(null);
  469. if (lastRequestRef.current) {
  470. lastRequestRef.current.cancel();
  471. }
  472. if (lastStatsRequestRef.current) {
  473. lastStatsRequestRef.current.cancel();
  474. }
  475. if (lastFetchCountsRequestRef.current) {
  476. lastFetchCountsRequestRef.current.cancel();
  477. }
  478. pollerRef.current?.disable();
  479. lastRequestRef.current = api.request(
  480. `/organizations/${organization.slug}/issues/`,
  481. {
  482. method: 'GET',
  483. data: qs.stringify(requestParams),
  484. success: (data, _, resp) => {
  485. if (!resp) {
  486. return;
  487. }
  488. // If this is a direct hit, we redirect to the intended result directly.
  489. if (resp.getResponseHeader('X-Sentry-Direct-Hit') === '1') {
  490. let redirect: string;
  491. if (data[0]?.matchingEventId) {
  492. const {id, matchingEventId} = data[0];
  493. redirect = `/organizations/${organization.slug}/issues/${id}/events/${matchingEventId}/`;
  494. } else {
  495. const {id} = data[0];
  496. redirect = `/organizations/${organization.slug}/issues/${id}/`;
  497. }
  498. browserHistory.replace(
  499. normalizeUrl({
  500. pathname: redirect,
  501. query: {
  502. referrer: 'issue-list',
  503. ...extractSelectionParameters(location.query),
  504. },
  505. })
  506. );
  507. return;
  508. }
  509. if (undoRef.current) {
  510. GroupStore.loadInitialData(data);
  511. }
  512. GroupStore.add(data);
  513. fetchStats(data.map((group: BaseGroup) => group.id));
  514. const hits = resp.getResponseHeader('X-Hits');
  515. const newQueryCount =
  516. typeof hits !== 'undefined' && hits ? parseInt(hits, 10) || 0 : 0;
  517. const maxHits = resp.getResponseHeader('X-Max-Hits');
  518. const newQueryMaxCount =
  519. typeof maxHits !== 'undefined' && maxHits ? parseInt(maxHits, 10) || 0 : 0;
  520. const newPageLinks = resp.getResponseHeader('Link');
  521. fetchCounts(newQueryCount, fetchAllCounts);
  522. setError(null);
  523. setIssuesLoading(false);
  524. setQueryCount(newQueryCount);
  525. setQueryMaxCount(newQueryMaxCount);
  526. setPageLinks(newPageLinks !== null ? newPageLinks : '');
  527. IssueListCacheStore.save(requestParams, {
  528. groups: GroupStore.getState() as Group[],
  529. queryCount: newQueryCount,
  530. queryMaxCount: newQueryMaxCount,
  531. pageLinks: newPageLinks ?? '',
  532. });
  533. if (data.length === 0) {
  534. trackAnalytics('issue_search.empty', {
  535. organization,
  536. search_type: 'issues',
  537. search_source: 'main_search',
  538. query,
  539. });
  540. }
  541. },
  542. error: err => {
  543. trackAnalytics('issue_search.failed', {
  544. organization,
  545. search_type: 'issues',
  546. search_source: 'main_search',
  547. error: parseApiError(err),
  548. });
  549. setError(parseApiError(err));
  550. setIssuesLoading(false);
  551. },
  552. complete: () => {
  553. lastRequestRef.current = null;
  554. resumePolling();
  555. if (!realtimeActive) {
  556. actionTakenRef.current = false;
  557. undoRef.current = false;
  558. }
  559. },
  560. }
  561. );
  562. },
  563. [
  564. api,
  565. fetchCounts,
  566. fetchStats,
  567. query,
  568. sort,
  569. location.query,
  570. organization,
  571. realtimeActive,
  572. requestParams,
  573. resumePolling,
  574. ]
  575. );
  576. useRouteAnalyticsParams({
  577. issue_views_enabled: organization.features.includes('issue-stream-custom-views'),
  578. });
  579. useDisableRouteAnalytics();
  580. // Update polling status
  581. useEffect(() => {
  582. if (realtimeActive) {
  583. resumePolling();
  584. } else {
  585. pollerRef.current?.disable();
  586. }
  587. }, [realtimeActive, resumePolling]);
  588. // Fetch data on mount if necessary
  589. useEffect(() => {
  590. const loadedFromCache = loadFromCache();
  591. if (!loadedFromCache) {
  592. // It's possible the projects query parameter is not yet ready and this
  593. // request will be repeated in componentDidUpdate
  594. fetchData();
  595. }
  596. // eslint-disable-next-line react-hooks/exhaustive-deps
  597. }, []);
  598. const previousSelection = usePrevious(selection);
  599. const previousSavedSearchLoading = usePrevious(savedSearchLoading);
  600. const previousIssuesLoading = usePrevious(issuesLoading);
  601. const previousRequestParams = usePrevious(requestParams);
  602. // Keep data up to date
  603. useEffect(() => {
  604. const selectionChanged = !isEqual(previousSelection, selection);
  605. // Wait for saved searches to load before we attempt to fetch stream data
  606. // Selection changing could indicate that the projects query parameter has populated
  607. // and we should refetch data.
  608. if (savedSearchLoading && !selectionChanged) {
  609. return;
  610. }
  611. if (previousSavedSearchLoading && !savedSearchLoading) {
  612. return;
  613. }
  614. // If any important url parameter changed or saved search changed
  615. // reload data.
  616. if (!isEqual(previousRequestParams, requestParams)) {
  617. fetchData(selectionChanged);
  618. } else if (
  619. !lastRequestRef.current &&
  620. previousIssuesLoading === false &&
  621. issuesLoading
  622. ) {
  623. // Reload if we issues are loading or their loading state changed.
  624. // This can happen when transitionTo is called
  625. fetchData();
  626. }
  627. }, [
  628. fetchData,
  629. savedSearchLoading,
  630. selection,
  631. previousSelection,
  632. organization.features,
  633. issuesLoading,
  634. loadFromCache,
  635. previousSavedSearchLoading,
  636. previousIssuesLoading,
  637. previousRequestParams,
  638. requestParams,
  639. ]);
  640. // Fetch members on mount
  641. useEffect(() => {
  642. const projectIds = selection.projects.map(projectId => String(projectId));
  643. fetchOrgMembers(api, organization.slug, projectIds).then(members => {
  644. setMemberList(indexMembersByProject(members));
  645. });
  646. // eslint-disable-next-line react-hooks/exhaustive-deps
  647. }, []);
  648. // If the project selection has changed reload the member list and tag keys
  649. // allowing autocomplete and tag sidebar to be more accurate.
  650. useEffect(() => {
  651. if (isEqual(previousSelection?.projects, selection.projects)) {
  652. return;
  653. }
  654. const projectIds = selection.projects.map(projectId => String(projectId));
  655. fetchOrgMembers(api, organization.slug, projectIds).then(members => {
  656. setMemberList(indexMembersByProject(members));
  657. });
  658. }, [api, organization.slug, selection.projects, previousSelection?.projects]);
  659. // Cleanup
  660. useEffect(() => {
  661. return () => {
  662. pollerRef.current?.disable();
  663. SelectedGroupStore.reset();
  664. GroupStore.reset();
  665. };
  666. }, []);
  667. const allResultsVisible = useCallback(() => {
  668. if (!pageLinks) {
  669. return false;
  670. }
  671. const links = parseLinkHeader(pageLinks);
  672. return links && !links.previous!.results && !links.next!.results;
  673. }, [pageLinks]);
  674. const getPageCounts = useCallback(() => {
  675. const links = parseLinkHeader(pageLinks);
  676. const queryPageInt = parseInt(location.query.page, 10);
  677. // Cursor must be present for the page number to be used
  678. const page = isNaN(queryPageInt) || !location.query.cursor ? 0 : queryPageInt;
  679. let numPreviousIssues = Math.min(page * MAX_ITEMS, queryCount);
  680. // Because the query param `page` is not tied to the request, we need to
  681. // validate that it's correct at the first and last page
  682. if (!links?.next?.results || allResultsVisible()) {
  683. // On last available page
  684. numPreviousIssues = Math.max(queryCount - groupIds.length, 0);
  685. } else if (!links?.previous?.results) {
  686. // On first available page
  687. numPreviousIssues = 0;
  688. }
  689. return {
  690. numPreviousIssues,
  691. numIssuesOnPage: groupIds.length,
  692. };
  693. }, [
  694. pageLinks,
  695. location.query.page,
  696. location.query.cursor,
  697. queryCount,
  698. allResultsVisible,
  699. groupIds.length,
  700. ]);
  701. const onRealtimeChange = useCallback(
  702. (realtime: boolean) => {
  703. Cookies.set('realtimeActive', realtime.toString());
  704. setRealtimeActive(realtime);
  705. trackAnalytics('issues_stream.realtime_clicked', {
  706. organization,
  707. enabled: realtime,
  708. });
  709. },
  710. [organization]
  711. );
  712. const transitionTo = (
  713. newParams: Partial<EndpointParams> = {},
  714. newSavedSearch: (SavedSearch & {projectId?: number}) | null = savedSearch
  715. ) => {
  716. const queryData = {
  717. ...omit(location.query, ['page', 'cursor']),
  718. referrer: 'issue-list',
  719. ...getEndpointParams(),
  720. ...newParams,
  721. };
  722. let path: string;
  723. if (newSavedSearch?.id) {
  724. path = `/organizations/${organization.slug}/issues/searches/${newSavedSearch.id}/`;
  725. // Remove the query as saved searches bring their own query string.
  726. delete queryData.query;
  727. // If we aren't going to another page in the same search
  728. // drop the query and replace the current project, with the saved search search project
  729. // if available.
  730. if (!queryData.cursor && newSavedSearch.projectId) {
  731. queryData.project = [newSavedSearch.projectId];
  732. }
  733. if (!queryData.cursor && !newParams.sort && newSavedSearch.sort) {
  734. queryData.sort = newSavedSearch.sort;
  735. }
  736. } else {
  737. path = `/organizations/${organization.slug}/issues/`;
  738. }
  739. if (
  740. queryData.sort === IssueSortOptions.INBOX &&
  741. !FOR_REVIEW_QUERIES.includes(queryData.query || '')
  742. ) {
  743. delete queryData.sort;
  744. }
  745. if (path !== location.pathname || !isEqual(query, location.query)) {
  746. browserHistory.push({
  747. pathname: normalizeUrl(path),
  748. query: queryData,
  749. });
  750. setIssuesLoading(true);
  751. }
  752. };
  753. const onSearch = (newQuery: string) => {
  754. if (newQuery === query) {
  755. // if query is the same, just re-fetch data
  756. fetchData();
  757. } else {
  758. // Clear the saved search as the user wants something else.
  759. transitionTo({query: newQuery}, null);
  760. }
  761. };
  762. const onSortChange = (newSort: string) => {
  763. trackAnalytics('issues_stream.sort_changed', {
  764. organization,
  765. sort: newSort,
  766. });
  767. transitionTo({sort: newSort});
  768. };
  769. const onCursorChange: CursorHandler = (nextCursor, _path, _query, delta) => {
  770. const queryPageInt = parseInt(location.query.page, 10);
  771. let nextPage: number | undefined = isNaN(queryPageInt) ? delta : queryPageInt + delta;
  772. let cursor: undefined | string = nextCursor;
  773. // unset cursor and page when we navigate back to the first page
  774. // also reset cursor if somehow the previous button is enabled on
  775. // first page and user attempts to go backwards
  776. if (nextPage <= 0) {
  777. cursor = undefined;
  778. nextPage = undefined;
  779. }
  780. transitionTo({cursor, page: nextPage});
  781. };
  782. const onSelectStatsPeriod = (period: string) => {
  783. if (period !== getGroupStatsPeriod()) {
  784. const cursor = location.query.cursor;
  785. const queryPageInt = parseInt(location.query.page, 10);
  786. const page = isNaN(queryPageInt) || !location.query.cursor ? 0 : queryPageInt;
  787. transitionTo({cursor, page, groupStatsPeriod: period});
  788. }
  789. };
  790. const undoAction = ({
  791. data,
  792. groupItems,
  793. }: {
  794. data: IssueUpdateData;
  795. groupItems: BaseGroup[];
  796. }) => {
  797. const projectIds = selection?.projects?.map(p => p.toString());
  798. const endpoint = `/organizations/${organization.slug}/issues/`;
  799. if (lastRequestRef.current) {
  800. lastRequestRef.current.cancel();
  801. }
  802. if (lastStatsRequestRef.current) {
  803. lastStatsRequestRef.current.cancel();
  804. }
  805. if (lastFetchCountsRequestRef.current) {
  806. lastFetchCountsRequestRef.current.cancel();
  807. }
  808. api.request(endpoint, {
  809. method: 'PUT',
  810. data,
  811. query: {
  812. project: projectIds,
  813. id: groupItems.map(group => group.id),
  814. },
  815. success: response => {
  816. if (!response) {
  817. return;
  818. }
  819. // If on the Ignore or For Review tab, adding back to the GroupStore will make the issue show up
  820. // on this page for a second and then be removed (will show up on All Unresolved). This is to
  821. // stop this from happening and avoid confusion.
  822. if (!query.includes('is:ignored') && !isForReviewQuery(query)) {
  823. GroupStore.add(groupItems);
  824. }
  825. actionTakenRef.current = true;
  826. },
  827. error: err => {
  828. setError(parseApiError(err));
  829. setIssuesLoading(false);
  830. },
  831. complete: () => {
  832. fetchData(true);
  833. },
  834. });
  835. };
  836. const onIssueAction = ({
  837. itemIds,
  838. actionType,
  839. shouldRemove,
  840. undo,
  841. }: {
  842. actionType: 'Reviewed' | 'Resolved' | 'Ignored' | 'Archived' | 'Reprioritized';
  843. itemIds: string[];
  844. shouldRemove: boolean;
  845. undo?: () => void;
  846. }) => {
  847. if (itemIds.length > 1) {
  848. addMessage(`${actionType} ${itemIds.length} ${t('Issues')}`, 'success', {
  849. duration: 4000,
  850. undo,
  851. });
  852. } else {
  853. const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).toString();
  854. addMessage(`${actionType} ${shortId}`, 'success', {
  855. duration: 4000,
  856. undo,
  857. });
  858. }
  859. if (!shouldRemove) {
  860. return;
  861. }
  862. const links = parseLinkHeader(pageLinks);
  863. GroupStore.remove(itemIds);
  864. const newQueryCount = queryCount - itemIds.length;
  865. actionTakenRef.current = true;
  866. setQueryCount(newQueryCount);
  867. if (GroupStore.getAllItemIds().length === 0) {
  868. // If we run out of issues on the last page, navigate back a page to
  869. // avoid showing an empty state - if not on the last page, just show a spinner
  870. const shouldGoBackAPage = links?.previous?.results && !links?.next?.results;
  871. transitionTo({cursor: shouldGoBackAPage ? links.previous!.cursor : undefined});
  872. fetchCounts(newQueryCount, true);
  873. } else {
  874. fetchData(true);
  875. }
  876. };
  877. const onActionTaken = (itemIds: string[], data: IssueUpdateData) => {
  878. if (realtimeActive) {
  879. return;
  880. }
  881. const groupItems = itemIds.map(id => GroupStore.get(id)).filter(defined);
  882. if ('status' in data) {
  883. if (data.status === 'resolved') {
  884. onIssueAction({
  885. itemIds,
  886. actionType: 'Resolved',
  887. shouldRemove:
  888. query.includes('is:unresolved') ||
  889. query.includes('is:ignored') ||
  890. isForReviewQuery(query),
  891. undo: () =>
  892. undoAction({
  893. data: {status: GroupStatus.UNRESOLVED, statusDetails: {}},
  894. groupItems,
  895. }),
  896. });
  897. return;
  898. }
  899. if (data.status === 'ignored') {
  900. onIssueAction({
  901. itemIds,
  902. actionType: 'Archived',
  903. shouldRemove: query.includes('is:unresolved') || isForReviewQuery(query),
  904. undo: () =>
  905. undoAction({
  906. data: {status: GroupStatus.UNRESOLVED, statusDetails: {}},
  907. groupItems,
  908. }),
  909. });
  910. return;
  911. }
  912. }
  913. if ('inbox' in data && data.inbox === false) {
  914. onIssueAction({
  915. itemIds,
  916. actionType: 'Reviewed',
  917. shouldRemove: isForReviewQuery(query),
  918. });
  919. return;
  920. }
  921. if ('priority' in data && typeof data.priority === 'string') {
  922. const priorityValues = parseIssuePrioritySearch(query);
  923. const priority = data.priority.toLowerCase() as PriorityLevel;
  924. onIssueAction({
  925. itemIds,
  926. actionType: 'Reprioritized',
  927. shouldRemove: !priorityValues.has(priority),
  928. });
  929. return;
  930. }
  931. };
  932. const onDelete = () => {
  933. actionTakenRef.current = true;
  934. fetchData(true);
  935. };
  936. const paginationAnalyticsEvent = (direction: string) => {
  937. trackAnalytics('issues_stream.paginate', {
  938. organization,
  939. direction,
  940. });
  941. };
  942. const onSavedSearchSelect = (newSavedSearch: SavedSearch) => {
  943. trackAnalytics('organization_saved_search.selected', {
  944. organization,
  945. search_type: 'issues',
  946. id: newSavedSearch.id ? parseInt(newSavedSearch.id, 10) : -1,
  947. is_global: newSavedSearch.isGlobal,
  948. query: newSavedSearch.query,
  949. visibility: newSavedSearch.visibility,
  950. });
  951. setIssuesLoading(true);
  952. setTimeout(() => {
  953. transitionTo(undefined, newSavedSearch);
  954. }, 0);
  955. };
  956. const modifiedQueryCount = Math.max(queryCount, 0);
  957. // TODO: these two might still be in use for reprocessing2
  958. const showReprocessingTab = !!queryCounts?.[Query.REPROCESSING]?.count;
  959. const displayReprocessingActions = showReprocessingTab && query === Query.REPROCESSING;
  960. const {numPreviousIssues, numIssuesOnPage} = getPageCounts();
  961. return (
  962. <NewTabContextProvider>
  963. <Layout.Page>
  964. {organization.features.includes('issue-stream-custom-views') ? (
  965. <ErrorBoundary message={'Failed to load custom tabs'} mini>
  966. <IssueViewsIssueListHeader
  967. router={router}
  968. selectedProjectIds={selection.projects}
  969. realtimeActive={realtimeActive}
  970. onRealtimeChange={onRealtimeChange}
  971. />
  972. </ErrorBoundary>
  973. ) : (
  974. <IssueListHeader
  975. organization={organization}
  976. query={query}
  977. sort={sort}
  978. queryCount={queryCount}
  979. queryCounts={queryCounts}
  980. realtimeActive={realtimeActive}
  981. router={router}
  982. displayReprocessingTab={showReprocessingTab}
  983. selectedProjectIds={selection.projects}
  984. onRealtimeChange={onRealtimeChange}
  985. />
  986. )}
  987. <StyledBody>
  988. <StyledMain>
  989. <IssuesDataConsentBanner source="issues" />
  990. <IssueListFilters
  991. query={query}
  992. sort={sort}
  993. onSortChange={onSortChange}
  994. onSearch={onSearch}
  995. />
  996. <IssueListTable
  997. selection={selection}
  998. query={query}
  999. queryCount={modifiedQueryCount}
  1000. onSelectStatsPeriod={onSelectStatsPeriod}
  1001. onActionTaken={onActionTaken}
  1002. onDelete={onDelete}
  1003. statsPeriod={getGroupStatsPeriod()}
  1004. groupIds={groupIds}
  1005. allResultsVisible={allResultsVisible()}
  1006. displayReprocessingActions={displayReprocessingActions}
  1007. sort={sort}
  1008. onSortChange={onSortChange}
  1009. memberList={memberList}
  1010. selectedProjectIds={selection.projects}
  1011. issuesLoading={issuesLoading}
  1012. error={error}
  1013. refetchGroups={fetchData}
  1014. paginationCaption={
  1015. !issuesLoading && modifiedQueryCount > 0
  1016. ? tct('[start]-[end] of [total]', {
  1017. start: numPreviousIssues + 1,
  1018. end: numPreviousIssues + numIssuesOnPage,
  1019. total: (
  1020. <QueryCount
  1021. hideParens
  1022. hideIfEmpty={false}
  1023. count={modifiedQueryCount}
  1024. max={queryMaxCount || 100}
  1025. />
  1026. ),
  1027. })
  1028. : null
  1029. }
  1030. pageLinks={pageLinks}
  1031. onCursor={onCursorChange}
  1032. paginationAnalyticsEvent={paginationAnalyticsEvent}
  1033. personalSavedSearches={savedSearches?.filter(
  1034. search => search.visibility === 'owner'
  1035. )}
  1036. organizationSavedSearches={savedSearches?.filter(
  1037. search => search.visibility === 'organization'
  1038. )}
  1039. />
  1040. </StyledMain>
  1041. <SavedIssueSearches
  1042. {...{organization, query}}
  1043. onSavedSearchSelect={onSavedSearchSelect}
  1044. sort={sort}
  1045. />
  1046. </StyledBody>
  1047. </Layout.Page>
  1048. </NewTabContextProvider>
  1049. );
  1050. }
  1051. export default withRouteAnalytics(
  1052. withPageFilters(
  1053. withSavedSearches(withOrganization(Sentry.withProfiler(IssueListOverviewFc)))
  1054. )
  1055. );
  1056. export {IssueListOverviewFc};
  1057. const StyledBody = styled('div')`
  1058. background-color: ${p => p.theme.background};
  1059. flex: 1;
  1060. display: grid;
  1061. gap: 0;
  1062. padding: 0;
  1063. grid-template-rows: 1fr;
  1064. grid-template-columns: minmax(0, 1fr) auto;
  1065. grid-template-areas: 'content saved-searches';
  1066. `;
  1067. const StyledMain = styled('section')`
  1068. grid-area: content;
  1069. padding: ${space(2)};
  1070. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  1071. padding: ${space(3)} ${space(4)};
  1072. }
  1073. `;