overview.tsx 36 KB

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