overview.tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288
  1. import * as React from 'react';
  2. import {browserHistory, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {withProfiler} from '@sentry/react';
  5. import * as Sentry from '@sentry/react';
  6. import {Location} from 'history';
  7. import Cookies from 'js-cookie';
  8. import isEqual from 'lodash/isEqual';
  9. import mapValues from 'lodash/mapValues';
  10. import omit from 'lodash/omit';
  11. import pickBy from 'lodash/pickBy';
  12. import * as qs from 'query-string';
  13. import {addMessage} from 'sentry/actionCreators/indicator';
  14. import {fetchOrgMembers, indexMembersByProject} from 'sentry/actionCreators/members';
  15. import {
  16. deleteSavedSearch,
  17. fetchSavedSearches,
  18. resetSavedSearches,
  19. } from 'sentry/actionCreators/savedSearches';
  20. import {fetchTagValues, loadOrganizationTags} from 'sentry/actionCreators/tags';
  21. import GroupActions from 'sentry/actions/groupActions';
  22. import {Client} from 'sentry/api';
  23. import * as Layout from 'sentry/components/layouts/thirds';
  24. import LoadingError from 'sentry/components/loadingError';
  25. import LoadingIndicator from 'sentry/components/loadingIndicator';
  26. import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
  27. import Pagination, {CursorHandler} from 'sentry/components/pagination';
  28. import {Panel, PanelBody} from 'sentry/components/panels';
  29. import QueryCount from 'sentry/components/queryCount';
  30. import StreamGroup from 'sentry/components/stream/group';
  31. import ProcessingIssueList from 'sentry/components/stream/processingIssueList';
  32. import {DEFAULT_QUERY, DEFAULT_STATS_PERIOD} from 'sentry/constants';
  33. import {t, tct} from 'sentry/locale';
  34. import GroupStore from 'sentry/stores/groupStore';
  35. import {PageContent} from 'sentry/styles/organization';
  36. import {
  37. BaseGroup,
  38. Group,
  39. Member,
  40. Organization,
  41. PageFilters,
  42. SavedSearch,
  43. TagCollection,
  44. } from 'sentry/types';
  45. import {defined} from 'sentry/utils';
  46. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  47. import {callIfFunction} from 'sentry/utils/callIfFunction';
  48. import CursorPoller from 'sentry/utils/cursorPoller';
  49. import {getUtcDateString} from 'sentry/utils/dates';
  50. import getCurrentSentryReactTransaction from 'sentry/utils/getCurrentSentryReactTransaction';
  51. import parseApiError from 'sentry/utils/parseApiError';
  52. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  53. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  54. import StreamManager from 'sentry/utils/streamManager';
  55. import withApi from 'sentry/utils/withApi';
  56. import withIssueTags from 'sentry/utils/withIssueTags';
  57. import withOrganization from 'sentry/utils/withOrganization';
  58. import withPageFilters from 'sentry/utils/withPageFilters';
  59. import withSavedSearches from 'sentry/utils/withSavedSearches';
  60. import IssueListActions from './actions';
  61. import IssueListFilters from './filters';
  62. import IssueListHeader from './header';
  63. import NoGroupsHandler from './noGroupsHandler';
  64. import IssueListSidebar from './sidebar';
  65. import {
  66. getTabs,
  67. getTabsWithCounts,
  68. isForReviewQuery,
  69. IssueDisplayOptions,
  70. IssueSortOptions,
  71. Query,
  72. QueryCounts,
  73. TAB_MAX_COUNT,
  74. } from './utils';
  75. const MAX_ITEMS = 25;
  76. const DEFAULT_SORT = IssueSortOptions.DATE;
  77. const DEFAULT_DISPLAY = IssueDisplayOptions.EVENTS;
  78. // the default period for the graph in each issue row
  79. const DEFAULT_GRAPH_STATS_PERIOD = '24h';
  80. // the allowed period choices for graph in each issue row
  81. const DYNAMIC_COUNTS_STATS_PERIODS = new Set(['14d', '24h', 'auto']);
  82. type Params = {
  83. orgId: string;
  84. };
  85. type Props = {
  86. api: Client;
  87. location: Location;
  88. organization: Organization;
  89. params: Params;
  90. savedSearch: SavedSearch;
  91. savedSearchLoading: boolean;
  92. savedSearches: SavedSearch[];
  93. selection: PageFilters;
  94. tags: TagCollection;
  95. } & RouteComponentProps<{searchId?: string}, {}>;
  96. type State = {
  97. actionTaken: boolean;
  98. error: string | null;
  99. // TODO(Kelly): remove forReview once issue-list-removal-action feature is stable
  100. forReview: boolean;
  101. groupIds: string[];
  102. isSidebarVisible: boolean;
  103. issuesLoading: boolean;
  104. itemsRemoved: number;
  105. memberList: ReturnType<typeof indexMembersByProject>;
  106. pageLinks: string;
  107. /**
  108. * Current query total
  109. */
  110. queryCount: number;
  111. /**
  112. * Counts for each inbox tab
  113. */
  114. queryCounts: QueryCounts;
  115. queryMaxCount: number;
  116. realtimeActive: boolean;
  117. // TODO(Kelly): remove reviewedIds once issue-list-removal-action feature is stable
  118. reviewedIds: string[];
  119. selectAllActive: boolean;
  120. tagsLoading: boolean;
  121. // Will be set to true if there is valid session data from issue-stats api call
  122. query?: string;
  123. };
  124. type EndpointParams = Partial<PageFilters['datetime']> & {
  125. environment: string[];
  126. project: number[];
  127. cursor?: string;
  128. display?: string;
  129. groupStatsPeriod?: string | null;
  130. page?: number | string;
  131. query?: string;
  132. sort?: string;
  133. statsPeriod?: string | null;
  134. };
  135. type CountsEndpointParams = Omit<EndpointParams, 'cursor' | 'page' | 'query'> & {
  136. query: string[];
  137. };
  138. type StatEndpointParams = Omit<EndpointParams, 'cursor' | 'page'> & {
  139. groups: string[];
  140. expand?: string | string[];
  141. };
  142. class IssueListOverview extends React.Component<Props, State> {
  143. state: State = this.getInitialState();
  144. getInitialState() {
  145. const realtimeActiveCookie = Cookies.get('realtimeActive');
  146. const realtimeActive =
  147. typeof realtimeActiveCookie === 'undefined'
  148. ? false
  149. : realtimeActiveCookie === 'true';
  150. return {
  151. groupIds: [],
  152. // TODO(Kelly): remove reviewedIds and forReview once issue-list-removal-action feature is stable
  153. reviewedIds: [],
  154. forReview: false,
  155. actionTaken: false,
  156. selectAllActive: false,
  157. realtimeActive,
  158. pageLinks: '',
  159. itemsRemoved: 0,
  160. queryCount: 0,
  161. queryCounts: {},
  162. queryMaxCount: 0,
  163. error: null,
  164. isSidebarVisible: false,
  165. issuesLoading: true,
  166. tagsLoading: true,
  167. memberList: {},
  168. };
  169. }
  170. componentDidMount() {
  171. const links = parseLinkHeader(this.state.pageLinks);
  172. this._poller = new CursorPoller({
  173. endpoint: links.previous?.href || '',
  174. success: this.onRealtimePoll,
  175. });
  176. // Start by getting searches first so if the user is on a saved search
  177. // or they have a pinned search we load the correct data the first time.
  178. this.fetchSavedSearches();
  179. this.fetchTags();
  180. this.fetchMemberList();
  181. }
  182. componentDidUpdate(prevProps: Props, prevState: State) {
  183. if (prevState.realtimeActive !== this.state.realtimeActive) {
  184. // User toggled realtime button
  185. if (this.state.realtimeActive) {
  186. this.resumePolling();
  187. } else {
  188. this._poller.disable();
  189. }
  190. }
  191. // If the project selection has changed reload the member list and tag keys
  192. // allowing autocomplete and tag sidebar to be more accurate.
  193. if (!isEqual(prevProps.selection.projects, this.props.selection.projects)) {
  194. this.fetchMemberList();
  195. this.fetchTags();
  196. // Reset display when selecting multiple projects
  197. const projects = this.props.selection.projects ?? [];
  198. const hasMultipleProjects = projects.length !== 1 || projects[0] === -1;
  199. if (hasMultipleProjects && this.getDisplay() !== DEFAULT_DISPLAY) {
  200. this.transitionTo({display: undefined});
  201. }
  202. }
  203. // TODO(Kelly): remove once issue-list-removal-action feature is stable
  204. if (!this.props.organization.features.includes('issue-list-removal-action')) {
  205. if (prevState.forReview !== this.state.forReview) {
  206. this.fetchData();
  207. }
  208. }
  209. // Wait for saved searches to load before we attempt to fetch stream data
  210. if (this.props.savedSearchLoading) {
  211. return;
  212. }
  213. if (prevProps.savedSearchLoading) {
  214. this.fetchData();
  215. return;
  216. }
  217. const prevQuery = prevProps.location.query;
  218. const newQuery = this.props.location.query;
  219. const selectionChanged = !isEqual(prevProps.selection, this.props.selection);
  220. // If any important url parameter changed or saved search changed
  221. // reload data.
  222. if (
  223. selectionChanged ||
  224. prevQuery.cursor !== newQuery.cursor ||
  225. prevQuery.sort !== newQuery.sort ||
  226. prevQuery.query !== newQuery.query ||
  227. prevQuery.statsPeriod !== newQuery.statsPeriod ||
  228. prevQuery.groupStatsPeriod !== newQuery.groupStatsPeriod ||
  229. prevProps.savedSearch !== this.props.savedSearch
  230. ) {
  231. this.fetchData(selectionChanged);
  232. } else if (
  233. !this._lastRequest &&
  234. prevState.issuesLoading === false &&
  235. this.state.issuesLoading
  236. ) {
  237. // Reload if we issues are loading or their loading state changed.
  238. // This can happen when transitionTo is called
  239. this.fetchData();
  240. }
  241. }
  242. componentWillUnmount() {
  243. this._poller.disable();
  244. GroupStore.reset();
  245. this.props.api.clear();
  246. callIfFunction(this.listener);
  247. // Reset store when unmounting because we always fetch on mount
  248. // This means if you navigate away from stream and then back to stream,
  249. // this component will go from:
  250. // "ready" ->
  251. // "loading" (because fetching saved searches) ->
  252. // "ready"
  253. //
  254. // We don't render anything until saved searches is ready, so this can
  255. // cause weird side effects (e.g. ProcessingIssueList mounting and making
  256. // a request, but immediately unmounting when fetching saved searches)
  257. resetSavedSearches();
  258. }
  259. private _poller: any;
  260. private _lastRequest: any;
  261. private _lastStatsRequest: any;
  262. private _lastFetchCountsRequest: any;
  263. private _streamManager = new StreamManager(GroupStore);
  264. getQuery(): string {
  265. const {savedSearch, location} = this.props;
  266. if (savedSearch) {
  267. return savedSearch.query;
  268. }
  269. const {query} = location.query;
  270. if (query !== undefined) {
  271. return query as string;
  272. }
  273. return DEFAULT_QUERY;
  274. }
  275. getSort(): string {
  276. const {location, savedSearch} = this.props;
  277. if (!location.query.sort && savedSearch?.id) {
  278. return savedSearch.sort;
  279. }
  280. if (location.query.sort) {
  281. return location.query.sort as string;
  282. }
  283. return DEFAULT_SORT;
  284. }
  285. getDisplay(): IssueDisplayOptions {
  286. const {organization, location} = this.props;
  287. if (organization.features.includes('issue-percent-display')) {
  288. if (
  289. location.query.display &&
  290. Object.values(IssueDisplayOptions).includes(location.query.display)
  291. ) {
  292. return location.query.display as IssueDisplayOptions;
  293. }
  294. }
  295. return DEFAULT_DISPLAY;
  296. }
  297. getGroupStatsPeriod(): string {
  298. let currentPeriod: string;
  299. if (typeof this.props.location.query?.groupStatsPeriod === 'string') {
  300. currentPeriod = this.props.location.query.groupStatsPeriod;
  301. } else if (this.getSort() === IssueSortOptions.TREND) {
  302. // Default to the larger graph when sorting by relative change
  303. currentPeriod = 'auto';
  304. } else {
  305. currentPeriod = DEFAULT_GRAPH_STATS_PERIOD;
  306. }
  307. return DYNAMIC_COUNTS_STATS_PERIODS.has(currentPeriod)
  308. ? currentPeriod
  309. : DEFAULT_GRAPH_STATS_PERIOD;
  310. }
  311. getEndpointParams = (): EndpointParams => {
  312. const {selection} = this.props;
  313. const params: EndpointParams = {
  314. project: selection.projects,
  315. environment: selection.environments,
  316. query: this.getQuery(),
  317. ...selection.datetime,
  318. };
  319. if (selection.datetime.period) {
  320. delete params.period;
  321. params.statsPeriod = selection.datetime.period;
  322. }
  323. if (params.end) {
  324. params.end = getUtcDateString(params.end);
  325. }
  326. if (params.start) {
  327. params.start = getUtcDateString(params.start);
  328. }
  329. const sort = this.getSort();
  330. if (sort !== DEFAULT_SORT) {
  331. params.sort = sort;
  332. }
  333. const display = this.getDisplay();
  334. if (display !== DEFAULT_DISPLAY) {
  335. params.display = display;
  336. }
  337. const groupStatsPeriod = this.getGroupStatsPeriod();
  338. if (groupStatsPeriod !== DEFAULT_GRAPH_STATS_PERIOD) {
  339. params.groupStatsPeriod = groupStatsPeriod;
  340. }
  341. // only include defined values.
  342. return pickBy(params, v => defined(v)) as EndpointParams;
  343. };
  344. getGlobalSearchProjectIds = () => {
  345. return this.props.selection.projects;
  346. };
  347. fetchMemberList() {
  348. const projectIds = this.getGlobalSearchProjectIds()?.map(projectId =>
  349. String(projectId)
  350. );
  351. fetchOrgMembers(this.props.api, this.props.organization.slug, projectIds).then(
  352. members => {
  353. this.setState({memberList: indexMembersByProject(members)});
  354. }
  355. );
  356. }
  357. fetchTags() {
  358. const {organization, selection} = this.props;
  359. this.setState({tagsLoading: true});
  360. loadOrganizationTags(this.props.api, organization.slug, selection).then(() =>
  361. this.setState({tagsLoading: false})
  362. );
  363. }
  364. fetchStats = (groups: string[]) => {
  365. // If we have no groups to fetch, just skip stats
  366. if (!groups.length) {
  367. return;
  368. }
  369. const requestParams: StatEndpointParams = {
  370. ...this.getEndpointParams(),
  371. groups,
  372. };
  373. // If no stats period values are set, use default
  374. if (!requestParams.statsPeriod && !requestParams.start) {
  375. requestParams.statsPeriod = DEFAULT_STATS_PERIOD;
  376. }
  377. if (this.props.organization.features.includes('issue-percent-display')) {
  378. requestParams.expand = 'sessions';
  379. }
  380. this._lastStatsRequest = this.props.api.request(this.getGroupStatsEndpoint(), {
  381. method: 'GET',
  382. data: qs.stringify(requestParams),
  383. success: data => {
  384. if (!data) {
  385. return;
  386. }
  387. GroupActions.populateStats(groups, data);
  388. },
  389. error: err => {
  390. this.setState({
  391. error: parseApiError(err),
  392. });
  393. },
  394. complete: () => {
  395. this._lastStatsRequest = null;
  396. // End navigation transaction to prevent additional page requests from impacting page metrics.
  397. // Other transactions include stacktrace preview request
  398. const currentTransaction = Sentry.getCurrentHub().getScope()?.getTransaction();
  399. if (currentTransaction?.op === 'navigation') {
  400. currentTransaction.finish();
  401. }
  402. },
  403. });
  404. };
  405. fetchCounts = (currentQueryCount: number, fetchAllCounts: boolean) => {
  406. const {organization} = this.props;
  407. const {queryCounts: _queryCounts} = this.state;
  408. let queryCounts: QueryCounts = {..._queryCounts};
  409. const endpointParams = this.getEndpointParams();
  410. const tabQueriesWithCounts = getTabsWithCounts(organization);
  411. const currentTabQuery = tabQueriesWithCounts.includes(endpointParams.query as Query)
  412. ? endpointParams.query
  413. : null;
  414. // Update the count based on the exact number of issues, these shown as is
  415. if (currentTabQuery) {
  416. queryCounts[currentTabQuery] = {
  417. count: currentQueryCount,
  418. hasMore: false,
  419. };
  420. const tab = getTabs(organization).find(
  421. ([tabQuery]) => currentTabQuery === tabQuery
  422. )?.[1];
  423. if (tab && !endpointParams.cursor) {
  424. trackAdvancedAnalyticsEvent('issues_tab.viewed', {
  425. organization,
  426. tab: tab.analyticsName,
  427. num_issues: queryCounts[currentTabQuery].count,
  428. });
  429. }
  430. }
  431. this.setState({queryCounts});
  432. // If all tabs' counts are fetched, skip and only set
  433. if (
  434. fetchAllCounts ||
  435. !tabQueriesWithCounts.every(tabQuery => queryCounts[tabQuery] !== undefined)
  436. ) {
  437. const requestParams: CountsEndpointParams = {
  438. ...omit(endpointParams, 'query'),
  439. // fetch the counts for the tabs whose counts haven't been fetched yet
  440. query: tabQueriesWithCounts.filter(_query => _query !== currentTabQuery),
  441. };
  442. // If no stats period values are set, use default
  443. if (!requestParams.statsPeriod && !requestParams.start) {
  444. requestParams.statsPeriod = DEFAULT_STATS_PERIOD;
  445. }
  446. this._lastFetchCountsRequest = this.props.api.request(
  447. this.getGroupCountsEndpoint(),
  448. {
  449. method: 'GET',
  450. data: qs.stringify(requestParams),
  451. success: data => {
  452. if (!data) {
  453. return;
  454. }
  455. // Counts coming from the counts endpoint is limited to 100, for >= 100 we display 99+
  456. queryCounts = {
  457. ...queryCounts,
  458. ...mapValues(data, (count: number) => ({
  459. count,
  460. hasMore: count > TAB_MAX_COUNT,
  461. })),
  462. };
  463. },
  464. error: err => {
  465. this.setState({
  466. error: parseApiError(err),
  467. });
  468. },
  469. complete: () => {
  470. this._lastFetchCountsRequest = null;
  471. this.setState({queryCounts});
  472. },
  473. }
  474. );
  475. }
  476. };
  477. fetchData = (fetchAllCounts = false) => {
  478. const {organization} = this.props;
  479. const query = this.getQuery();
  480. const hasIssueListRemovalAction = organization.features.includes(
  481. 'issue-list-removal-action'
  482. );
  483. // TODO(Kelly): update once issue-list-removal-action feature is stable
  484. if (hasIssueListRemovalAction && !this.state.realtimeActive) {
  485. if (!this.state.actionTaken) {
  486. GroupStore.loadInitialData([]);
  487. this._streamManager.reset();
  488. this.setState({
  489. issuesLoading: true,
  490. queryCount: 0,
  491. itemsRemoved: 0,
  492. error: null,
  493. });
  494. }
  495. } else {
  496. if (!this.state.reviewedIds.length || !isForReviewQuery(query)) {
  497. GroupStore.loadInitialData([]);
  498. this._streamManager.reset();
  499. this.setState({
  500. issuesLoading: true,
  501. queryCount: 0,
  502. itemsRemoved: 0,
  503. reviewedIds: [],
  504. error: null,
  505. });
  506. }
  507. }
  508. const transaction = getCurrentSentryReactTransaction();
  509. transaction?.setTag('query.sort', this.getSort());
  510. this.setState({
  511. queryCount: 0,
  512. itemsRemoved: 0,
  513. error: null,
  514. });
  515. const requestParams: any = {
  516. ...this.getEndpointParams(),
  517. limit: MAX_ITEMS,
  518. shortIdLookup: 1,
  519. };
  520. const currentQuery = this.props.location.query || {};
  521. if ('cursor' in currentQuery) {
  522. requestParams.cursor = currentQuery.cursor;
  523. }
  524. // If no stats period values are set, use default
  525. if (!requestParams.statsPeriod && !requestParams.start) {
  526. requestParams.statsPeriod = DEFAULT_STATS_PERIOD;
  527. }
  528. requestParams.expand = ['owners', 'inbox'];
  529. requestParams.collapse = 'stats';
  530. if (this._lastRequest) {
  531. this._lastRequest.cancel();
  532. }
  533. if (this._lastStatsRequest) {
  534. this._lastStatsRequest.cancel();
  535. }
  536. if (this._lastFetchCountsRequest) {
  537. this._lastFetchCountsRequest.cancel();
  538. }
  539. this._poller.disable();
  540. this._lastRequest = this.props.api.request(this.getGroupListEndpoint(), {
  541. method: 'GET',
  542. data: qs.stringify(requestParams),
  543. success: (data, _, resp) => {
  544. if (!resp) {
  545. return;
  546. }
  547. const {orgId} = this.props.params;
  548. // If this is a direct hit, we redirect to the intended result directly.
  549. if (resp.getResponseHeader('X-Sentry-Direct-Hit') === '1') {
  550. let redirect: string;
  551. if (data[0] && data[0].matchingEventId) {
  552. const {id, matchingEventId} = data[0];
  553. redirect = `/organizations/${orgId}/issues/${id}/events/${matchingEventId}/`;
  554. } else {
  555. const {id} = data[0];
  556. redirect = `/organizations/${orgId}/issues/${id}/`;
  557. }
  558. browserHistory.replace({
  559. pathname: redirect,
  560. query: extractSelectionParameters(this.props.location.query),
  561. });
  562. return;
  563. }
  564. this._streamManager.push(data);
  565. // TODO(Kelly): update once issue-list-removal-action feature is stable
  566. if (!hasIssueListRemovalAction) {
  567. if (isForReviewQuery(query)) {
  568. GroupStore.remove(this.state.reviewedIds);
  569. }
  570. }
  571. this.fetchStats(data.map((group: BaseGroup) => group.id));
  572. const hits = resp.getResponseHeader('X-Hits');
  573. const queryCount =
  574. typeof hits !== 'undefined' && hits ? parseInt(hits, 10) || 0 : 0;
  575. const maxHits = resp.getResponseHeader('X-Max-Hits');
  576. const queryMaxCount =
  577. typeof maxHits !== 'undefined' && maxHits ? parseInt(maxHits, 10) || 0 : 0;
  578. const pageLinks = resp.getResponseHeader('Link');
  579. // TODO(Kelly): update once issue-list-removal-action feature is stable
  580. if (hasIssueListRemovalAction && !this.state.realtimeActive) {
  581. this.fetchCounts(queryCount, fetchAllCounts);
  582. } else {
  583. if (!this.state.forReview) {
  584. this.fetchCounts(queryCount, fetchAllCounts);
  585. }
  586. }
  587. this.setState({
  588. error: null,
  589. issuesLoading: false,
  590. queryCount,
  591. queryMaxCount,
  592. pageLinks: pageLinks !== null ? pageLinks : '',
  593. });
  594. },
  595. error: err => {
  596. trackAdvancedAnalyticsEvent('issue_search.failed', {
  597. organization: this.props.organization,
  598. search_type: 'issues',
  599. search_source: 'main_search',
  600. error: parseApiError(err),
  601. });
  602. this.setState({
  603. error: parseApiError(err),
  604. issuesLoading: false,
  605. });
  606. },
  607. complete: () => {
  608. this._lastRequest = null;
  609. this.resumePolling();
  610. // TODO(Kelly): update once issue-list-removal-action feature is stable
  611. if (hasIssueListRemovalAction && !this.state.realtimeActive) {
  612. this.setState({actionTaken: false});
  613. } else {
  614. this.setState({forReview: false});
  615. }
  616. },
  617. });
  618. };
  619. resumePolling = () => {
  620. if (!this.state.pageLinks) {
  621. return;
  622. }
  623. // Only resume polling if we're on the first page of results
  624. const links = parseLinkHeader(this.state.pageLinks);
  625. if (links && !links.previous.results && this.state.realtimeActive) {
  626. // Remove collapse stats from endpoint before supplying to poller
  627. const issueEndpoint = new URL(links.previous.href, window.location.origin);
  628. issueEndpoint.searchParams.delete('collapse');
  629. this._poller.setEndpoint(decodeURIComponent(issueEndpoint.href));
  630. this._poller.enable();
  631. }
  632. };
  633. getGroupListEndpoint(): string {
  634. const {orgId} = this.props.params;
  635. return `/organizations/${orgId}/issues/`;
  636. }
  637. getGroupCountsEndpoint(): string {
  638. const {orgId} = this.props.params;
  639. return `/organizations/${orgId}/issues-count/`;
  640. }
  641. getGroupStatsEndpoint(): string {
  642. const {orgId} = this.props.params;
  643. return `/organizations/${orgId}/issues-stats/`;
  644. }
  645. onRealtimeChange = (realtime: boolean) => {
  646. Cookies.set('realtimeActive', realtime.toString());
  647. this.setState({realtimeActive: realtime});
  648. };
  649. onSelectStatsPeriod = (period: string) => {
  650. const {location} = this.props;
  651. if (period !== this.getGroupStatsPeriod()) {
  652. const cursor = location.query.cursor;
  653. const queryPageInt = parseInt(location.query.page, 10);
  654. const page = isNaN(queryPageInt) || !location.query.cursor ? 0 : queryPageInt;
  655. this.transitionTo({cursor, page, groupStatsPeriod: period});
  656. }
  657. };
  658. onRealtimePoll = (data: any, _links: any) => {
  659. // Note: We do not update state with cursors from polling,
  660. // `CursorPoller` updates itself with new cursors
  661. this._streamManager.unshift(data);
  662. };
  663. listener = GroupStore.listen(() => this.onGroupChange(), undefined);
  664. onGroupChange() {
  665. const {organization} = this.props;
  666. const query = this.getQuery();
  667. const hasIssueListRemovalAction = organization.features.includes(
  668. 'issue-list-removal-action'
  669. );
  670. // TODO(Kelly): update once issue-list-removal-action feature is stable
  671. if (hasIssueListRemovalAction && !this.state.realtimeActive) {
  672. const resolvedIds = this._streamManager
  673. .getAllItems()
  674. .filter(id => id.status === 'resolved')
  675. .map(item => item.id);
  676. const ignoredIds = this._streamManager
  677. .getAllItems()
  678. .filter(id => id.status === 'ignored')
  679. .map(item => item.id);
  680. const reviewedIds = this._streamManager
  681. .getAllItems()
  682. .filter(id => !id.inbox && id.status !== 'resolved' && id.status !== 'ignored')
  683. .map(item => item.id);
  684. // Remove Ignored and Resolved group ids from the issue stream, but if you have a query
  685. // that includes these statuses or there's no query/you want to see ALL issues,
  686. // don't trigger these group ids to be removed from the issue stream.
  687. if (resolvedIds.length > 0 && !query.includes('is:resolved') && !!query) {
  688. this.onIssueAction(resolvedIds, t('Resolved'));
  689. }
  690. if (ignoredIds.length > 0 && !query.includes('is:ignored') && !!query) {
  691. this.onIssueAction(ignoredIds, t('Ignored'));
  692. }
  693. // Remove issues that are marked as Reviewed from the For Review tab, but still include the
  694. // issues if not on the For Review tab, or no query for ALL issues.
  695. if (reviewedIds.length > 0 && isForReviewQuery(query) && !!query) {
  696. this.onIssueAction(reviewedIds, t('Reviewed'));
  697. }
  698. }
  699. const groupIds = this._streamManager.getAllItems().map(item => item.id) ?? [];
  700. if (!isEqual(groupIds, this.state.groupIds)) {
  701. this.setState({groupIds});
  702. }
  703. }
  704. onIssueListSidebarSearch = (query: string) => {
  705. trackAdvancedAnalyticsEvent('search.searched', {
  706. organization: this.props.organization,
  707. query,
  708. search_type: 'issues',
  709. search_source: 'search_builder',
  710. });
  711. this.onSearch(query);
  712. };
  713. onSearch = (query: string) => {
  714. if (query === this.state.query) {
  715. // if query is the same, just re-fetch data
  716. this.fetchData();
  717. } else {
  718. // Clear the saved search as the user wants something else.
  719. this.transitionTo({query}, null);
  720. }
  721. };
  722. onSortChange = (sort: string) => {
  723. this.transitionTo({sort});
  724. };
  725. onDisplayChange = (display: string) => {
  726. this.transitionTo({display});
  727. trackAdvancedAnalyticsEvent('search.display_changed', {
  728. organization: this.props.organization,
  729. });
  730. };
  731. onCursorChange: CursorHandler = (nextCursor, _path, _query, delta) => {
  732. const queryPageInt = parseInt(this.props.location.query.page, 10);
  733. let nextPage: number | undefined = isNaN(queryPageInt) ? delta : queryPageInt + delta;
  734. let cursor: undefined | string = nextCursor;
  735. // unset cursor and page when we navigate back to the first page
  736. // also reset cursor if somehow the previous button is enabled on
  737. // first page and user attempts to go backwards
  738. if (nextPage <= 0) {
  739. cursor = undefined;
  740. nextPage = undefined;
  741. }
  742. this.transitionTo({cursor, page: nextPage});
  743. };
  744. onSidebarToggle = () => {
  745. const {organization} = this.props;
  746. this.setState({
  747. isSidebarVisible: !this.state.isSidebarVisible,
  748. });
  749. trackAdvancedAnalyticsEvent('issue.search_sidebar_clicked', {
  750. organization,
  751. });
  752. };
  753. /**
  754. * Returns true if all results in the current query are visible/on this page
  755. */
  756. allResultsVisible(): boolean {
  757. if (!this.state.pageLinks) {
  758. return false;
  759. }
  760. const links = parseLinkHeader(this.state.pageLinks);
  761. return links && !links.previous.results && !links.next.results;
  762. }
  763. transitionTo = (
  764. newParams: Partial<EndpointParams> = {},
  765. savedSearch: (SavedSearch & {projectId?: number}) | null = this.props.savedSearch
  766. ) => {
  767. const query = {
  768. ...this.getEndpointParams(),
  769. ...newParams,
  770. };
  771. const {organization} = this.props;
  772. let path: string;
  773. if (savedSearch && savedSearch.id) {
  774. path = `/organizations/${organization.slug}/issues/searches/${savedSearch.id}/`;
  775. // Remove the query as saved searches bring their own query string.
  776. delete query.query;
  777. // If we aren't going to another page in the same search
  778. // drop the query and replace the current project, with the saved search search project
  779. // if available.
  780. if (!query.cursor && savedSearch.projectId) {
  781. query.project = [savedSearch.projectId];
  782. }
  783. if (!query.cursor && !newParams.sort && savedSearch.sort) {
  784. query.sort = savedSearch.sort;
  785. }
  786. } else {
  787. path = `/organizations/${organization.slug}/issues/`;
  788. }
  789. // Remove inbox tab specific sort
  790. if (query.sort === IssueSortOptions.INBOX && query.query !== Query.FOR_REVIEW) {
  791. delete query.sort;
  792. }
  793. if (
  794. path !== this.props.location.pathname ||
  795. !isEqual(query, this.props.location.query)
  796. ) {
  797. browserHistory.push({
  798. pathname: path,
  799. query,
  800. });
  801. this.setState({issuesLoading: true});
  802. }
  803. };
  804. displayReprocessingTab() {
  805. const {organization} = this.props;
  806. const {queryCounts} = this.state;
  807. return (
  808. organization.features.includes('reprocessing-v2') &&
  809. !!queryCounts?.[Query.REPROCESSING]?.count
  810. );
  811. }
  812. displayReprocessingLayout(showReprocessingTab: boolean, query: string) {
  813. return showReprocessingTab && query === Query.REPROCESSING;
  814. }
  815. renderGroupNodes = (ids: string[], groupStatsPeriod: string) => {
  816. const topIssue = ids[0];
  817. const {memberList} = this.state;
  818. const query = this.getQuery();
  819. const showInboxTime = this.getSort() === IssueSortOptions.INBOX;
  820. return ids.map((id, index) => {
  821. const hasGuideAnchor = id === topIssue;
  822. const group = GroupStore.get(id) as Group | undefined;
  823. let members: Member['user'][] | undefined;
  824. if (group?.project) {
  825. members = memberList[group.project.slug];
  826. }
  827. const showReprocessingTab = this.displayReprocessingTab();
  828. const displayReprocessingLayout = this.displayReprocessingLayout(
  829. showReprocessingTab,
  830. query
  831. );
  832. return (
  833. <StreamGroup
  834. index={index}
  835. key={id}
  836. id={id}
  837. statsPeriod={groupStatsPeriod}
  838. query={query}
  839. hasGuideAnchor={hasGuideAnchor}
  840. memberList={members}
  841. displayReprocessingLayout={displayReprocessingLayout}
  842. useFilteredStats
  843. showInboxTime={showInboxTime}
  844. display={this.getDisplay()}
  845. />
  846. );
  847. });
  848. };
  849. renderLoading(): React.ReactNode {
  850. return (
  851. <PageContent>
  852. <LoadingIndicator />
  853. </PageContent>
  854. );
  855. }
  856. renderStreamBody(): React.ReactNode {
  857. const {issuesLoading, error, groupIds} = this.state;
  858. if (issuesLoading) {
  859. return <LoadingIndicator hideMessage />;
  860. }
  861. if (error) {
  862. return <LoadingError message={error} onRetry={this.fetchData} />;
  863. }
  864. if (groupIds.length > 0) {
  865. return (
  866. <PanelBody>
  867. {this.renderGroupNodes(groupIds, this.getGroupStatsPeriod())}
  868. </PanelBody>
  869. );
  870. }
  871. const {api, organization, selection} = this.props;
  872. return (
  873. <NoGroupsHandler
  874. api={api}
  875. organization={organization}
  876. query={this.getQuery()}
  877. selectedProjectIds={selection.projects}
  878. groupIds={groupIds}
  879. />
  880. );
  881. }
  882. fetchSavedSearches = () => {
  883. const {organization, api} = this.props;
  884. fetchSavedSearches(api, organization.slug);
  885. };
  886. onSavedSearchSelect = (savedSearch: SavedSearch) => {
  887. trackAdvancedAnalyticsEvent('organization_saved_search.selected', {
  888. organization: this.props.organization,
  889. search_type: 'issues',
  890. id: savedSearch.id ? parseInt(savedSearch.id, 10) : -1,
  891. });
  892. this.setState({issuesLoading: true}, () => this.transitionTo(undefined, savedSearch));
  893. };
  894. onSavedSearchDelete = (search: SavedSearch) => {
  895. const {orgId} = this.props.params;
  896. deleteSavedSearch(this.props.api, orgId, search).then(() => {
  897. this.setState(
  898. {
  899. issuesLoading: true,
  900. },
  901. () => this.transitionTo({}, null)
  902. );
  903. });
  904. };
  905. onDelete = () => {
  906. this.setState({actionTaken: true});
  907. this.fetchData(true);
  908. };
  909. onMarkReviewed = (itemIds: string[]) => {
  910. const {organization} = this.props;
  911. const query = this.getQuery();
  912. const hasIssueListRemovalAction = organization.features.includes(
  913. 'issue-list-removal-action'
  914. );
  915. if (!isForReviewQuery(query)) {
  916. if (itemIds.length > 1) {
  917. addMessage(t(`Reviewed ${itemIds.length} Issues`), 'success', {duration: 4000});
  918. } else {
  919. const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).toString();
  920. addMessage(t(`Reviewed ${shortId}`), 'success', {duration: 4000});
  921. }
  922. return;
  923. }
  924. const {queryCounts, itemsRemoved} = this.state;
  925. const currentQueryCount = queryCounts[query as Query];
  926. if (itemIds.length && currentQueryCount) {
  927. const inInboxCount = itemIds.filter(id => GroupStore.get(id)?.inbox).length;
  928. currentQueryCount.count -= inInboxCount;
  929. // TODO(Kelly): update once issue-list-removal-action feature is stable
  930. if (!hasIssueListRemovalAction) {
  931. this.setState({
  932. reviewedIds: itemIds,
  933. forReview: true,
  934. });
  935. }
  936. this.setState({
  937. queryCounts: {
  938. ...queryCounts,
  939. [query as Query]: currentQueryCount,
  940. },
  941. itemsRemoved: itemsRemoved + inInboxCount,
  942. });
  943. }
  944. };
  945. onIssueAction = (itemIds: string[], actionType: string) => {
  946. if (itemIds.length > 1) {
  947. addMessage(t(`${actionType} ${itemIds.length} Issues`), 'success', {
  948. duration: 4000,
  949. });
  950. } else {
  951. const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).toString();
  952. addMessage(t(`${actionType} ${shortId}`), 'success', {duration: 4000});
  953. }
  954. GroupStore.remove(itemIds);
  955. this.setState({
  956. actionTaken: true,
  957. });
  958. this.fetchData(true);
  959. };
  960. tagValueLoader = (key: string, search: string) => {
  961. const {orgId} = this.props.params;
  962. const projectIds = this.getGlobalSearchProjectIds().map(id => id.toString());
  963. const endpointParams = this.getEndpointParams();
  964. return fetchTagValues(
  965. this.props.api,
  966. orgId,
  967. key,
  968. search,
  969. projectIds,
  970. endpointParams as any
  971. );
  972. };
  973. render() {
  974. if (this.props.savedSearchLoading) {
  975. return this.renderLoading();
  976. }
  977. const {
  978. isSidebarVisible,
  979. tagsLoading,
  980. pageLinks,
  981. queryCount,
  982. queryCounts,
  983. realtimeActive,
  984. groupIds,
  985. queryMaxCount,
  986. itemsRemoved,
  987. } = this.state;
  988. const {organization, savedSearch, savedSearches, tags, selection, location, router} =
  989. this.props;
  990. const links = parseLinkHeader(pageLinks);
  991. const query = this.getQuery();
  992. const queryPageInt = parseInt(location.query.page, 10);
  993. // Cursor must be present for the page number to be used
  994. const page = isNaN(queryPageInt) || !location.query.cursor ? 0 : queryPageInt;
  995. const pageBasedCount = page * MAX_ITEMS + groupIds.length;
  996. let pageCount = pageBasedCount > queryCount ? queryCount : pageBasedCount;
  997. if (!links?.next?.results || this.allResultsVisible()) {
  998. // On last available page
  999. pageCount = queryCount;
  1000. } else if (!links?.previous?.results) {
  1001. // On first available page
  1002. pageCount = groupIds.length;
  1003. }
  1004. // Subtract # items that have been marked reviewed
  1005. pageCount = Math.max(pageCount - itemsRemoved, 0);
  1006. const modifiedQueryCount = Math.max(queryCount - itemsRemoved, 0);
  1007. const displayCount = tct('[count] of [total]', {
  1008. count: pageCount,
  1009. total: (
  1010. <StyledQueryCount
  1011. hideParens
  1012. hideIfEmpty={false}
  1013. count={modifiedQueryCount}
  1014. max={queryMaxCount || 100}
  1015. />
  1016. ),
  1017. });
  1018. const projectIds = selection?.projects?.map(p => p.toString());
  1019. const showReprocessingTab = this.displayReprocessingTab();
  1020. const displayReprocessingActions = this.displayReprocessingLayout(
  1021. showReprocessingTab,
  1022. query
  1023. );
  1024. const layoutProps = {
  1025. fullWidth: !isSidebarVisible,
  1026. };
  1027. return (
  1028. <React.Fragment>
  1029. <StyledPageContent>
  1030. <IssueListHeader
  1031. organization={organization}
  1032. query={query}
  1033. sort={this.getSort()}
  1034. queryCount={queryCount}
  1035. queryCounts={queryCounts}
  1036. realtimeActive={realtimeActive}
  1037. onRealtimeChange={this.onRealtimeChange}
  1038. router={router}
  1039. savedSearchList={savedSearches}
  1040. onSavedSearchSelect={this.onSavedSearchSelect}
  1041. onSavedSearchDelete={this.onSavedSearchDelete}
  1042. displayReprocessingTab={showReprocessingTab}
  1043. selectedProjectIds={selection.projects}
  1044. />
  1045. <Layout.Body {...layoutProps}>
  1046. <Layout.Main {...layoutProps}>
  1047. <IssueListFilters
  1048. organization={organization}
  1049. query={query}
  1050. queryCount={queryCount}
  1051. savedSearch={savedSearch}
  1052. sort={this.getSort()}
  1053. display={this.getDisplay()}
  1054. onDisplayChange={this.onDisplayChange}
  1055. onSortChange={this.onSortChange}
  1056. onSearch={this.onSearch}
  1057. onSidebarToggle={this.onSidebarToggle}
  1058. isSearchDisabled={isSidebarVisible}
  1059. tagValueLoader={this.tagValueLoader}
  1060. tags={tags}
  1061. selectedProjects={selection.projects}
  1062. />
  1063. <Panel>
  1064. <IssueListActions
  1065. organization={organization}
  1066. selection={selection}
  1067. query={query}
  1068. queryCount={modifiedQueryCount}
  1069. displayCount={displayCount}
  1070. onSelectStatsPeriod={this.onSelectStatsPeriod}
  1071. onMarkReviewed={this.onMarkReviewed}
  1072. onDelete={this.onDelete}
  1073. statsPeriod={this.getGroupStatsPeriod()}
  1074. groupIds={groupIds}
  1075. allResultsVisible={this.allResultsVisible()}
  1076. displayReprocessingActions={displayReprocessingActions}
  1077. />
  1078. <PanelBody>
  1079. <ProcessingIssueList
  1080. organization={organization}
  1081. projectIds={projectIds}
  1082. showProject
  1083. />
  1084. <VisuallyCompleteWithData
  1085. hasData={this.state.groupIds.length > 0}
  1086. id="IssueList-Body"
  1087. >
  1088. {this.renderStreamBody()}
  1089. </VisuallyCompleteWithData>
  1090. </PanelBody>
  1091. </Panel>
  1092. <StyledPagination
  1093. caption={tct('Showing [displayCount] issues', {
  1094. displayCount,
  1095. })}
  1096. pageLinks={pageLinks}
  1097. onCursor={this.onCursorChange}
  1098. />
  1099. </Layout.Main>
  1100. {/* Avoid rendering sidebar until first accessed */}
  1101. {isSidebarVisible && (
  1102. <Layout.Side>
  1103. <IssueListSidebar
  1104. loading={tagsLoading}
  1105. tags={tags}
  1106. query={query}
  1107. onQueryChange={this.onIssueListSidebarSearch}
  1108. tagValueLoader={this.tagValueLoader}
  1109. />
  1110. </Layout.Side>
  1111. )}
  1112. </Layout.Body>
  1113. </StyledPageContent>
  1114. </React.Fragment>
  1115. );
  1116. }
  1117. }
  1118. export default withApi(
  1119. withPageFilters(
  1120. withSavedSearches(withOrganization(withIssueTags(withProfiler(IssueListOverview))))
  1121. )
  1122. );
  1123. export {IssueListOverview};
  1124. const StyledPagination = styled(Pagination)`
  1125. margin-top: 0;
  1126. `;
  1127. const StyledQueryCount = styled(QueryCount)`
  1128. margin-left: 0;
  1129. `;
  1130. const StyledPageContent = styled(PageContent)`
  1131. padding: 0;
  1132. `;