overviewFc.tsx 37 KB

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