overview.spec.jsx 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444
  1. import {browserHistory} from 'react-router';
  2. import merge from 'lodash/merge';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {
  5. act,
  6. render,
  7. screen,
  8. userEvent,
  9. waitFor,
  10. waitForElementToBeRemoved,
  11. } from 'sentry-test/reactTestingLibrary';
  12. import {textWithMarkupMatcher} from 'sentry-test/utils';
  13. import StreamGroup from 'sentry/components/stream/group';
  14. import ProjectsStore from 'sentry/stores/projectsStore';
  15. import TagStore from 'sentry/stores/tagStore';
  16. import {SavedSearchVisibility} from 'sentry/types';
  17. import localStorageWrapper from 'sentry/utils/localStorage';
  18. import * as parseLinkHeader from 'sentry/utils/parseLinkHeader';
  19. import IssueListWithStores, {IssueListOverview} from 'sentry/views/issueList/overview';
  20. // Mock <IssueListActions>
  21. jest.mock('sentry/views/issueList/actions', () => jest.fn(() => null));
  22. jest.mock('sentry/components/stream/group', () => jest.fn(() => null));
  23. const DEFAULT_LINKS_HEADER =
  24. '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575731:0:1>; rel="previous"; results="false"; cursor="1443575731:0:1", ' +
  25. '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575000:0:0>; rel="next"; results="true"; cursor="1443575000:0:0"';
  26. const project = TestStubs.ProjectDetails({
  27. id: '3559',
  28. name: 'Foo Project',
  29. slug: 'project-slug',
  30. firstEvent: true,
  31. });
  32. const {organization, router, routerContext} = initializeOrg({
  33. organization: {
  34. id: '1337',
  35. slug: 'org-slug',
  36. features: ['global-views'],
  37. access: ['releases'],
  38. },
  39. router: {
  40. location: {query: {}, search: ''},
  41. params: {},
  42. },
  43. project,
  44. });
  45. const routerProps = {
  46. params: router.params,
  47. location: router.location,
  48. };
  49. describe('IssueList', function () {
  50. let props;
  51. let group;
  52. let groupStats;
  53. let savedSearch;
  54. let fetchTagsRequest;
  55. let fetchMembersRequest;
  56. const api = new MockApiClient();
  57. const parseLinkHeaderSpy = jest.spyOn(parseLinkHeader, 'default');
  58. beforeEach(function () {
  59. // The tests fail because we have a "component update was not wrapped in act" error.
  60. // It should be safe to ignore this error, but we should remove the mock once we move to react testing library
  61. // eslint-disable-next-line no-console
  62. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  63. localStorageWrapper.clear();
  64. MockApiClient.clearMockResponses();
  65. savedSearch = TestStubs.Search({
  66. id: '789',
  67. query: 'is:unresolved TypeError',
  68. sort: 'date',
  69. name: 'Unresolved TypeErrors',
  70. projectId: project.id,
  71. });
  72. group = TestStubs.Group({project});
  73. MockApiClient.addMockResponse({
  74. url: '/organizations/org-slug/issues/',
  75. body: [group],
  76. headers: {
  77. Link: DEFAULT_LINKS_HEADER,
  78. },
  79. });
  80. groupStats = TestStubs.GroupStats();
  81. MockApiClient.addMockResponse({
  82. url: '/organizations/org-slug/issues-stats/',
  83. body: [groupStats],
  84. });
  85. MockApiClient.addMockResponse({
  86. url: '/organizations/org-slug/searches/',
  87. body: [savedSearch],
  88. });
  89. MockApiClient.addMockResponse({
  90. url: '/organizations/org-slug/recent-searches/',
  91. body: [],
  92. });
  93. MockApiClient.addMockResponse({
  94. url: '/organizations/org-slug/recent-searches/',
  95. method: 'POST',
  96. body: [],
  97. });
  98. MockApiClient.addMockResponse({
  99. url: '/organizations/org-slug/issues-count/',
  100. method: 'GET',
  101. body: [{}],
  102. });
  103. MockApiClient.addMockResponse({
  104. url: '/organizations/org-slug/processingissues/',
  105. method: 'GET',
  106. body: [
  107. {
  108. project: 'test-project',
  109. numIssues: 1,
  110. hasIssues: true,
  111. lastSeen: '2019-01-16T15:39:11.081Z',
  112. },
  113. ],
  114. });
  115. const tags = TestStubs.Tags();
  116. fetchTagsRequest = MockApiClient.addMockResponse({
  117. url: '/organizations/org-slug/tags/',
  118. method: 'GET',
  119. body: tags,
  120. });
  121. fetchMembersRequest = MockApiClient.addMockResponse({
  122. url: '/organizations/org-slug/users/',
  123. method: 'GET',
  124. body: [TestStubs.Member({projects: [project.slug]})],
  125. });
  126. MockApiClient.addMockResponse({
  127. url: '/organizations/org-slug/sent-first-event/',
  128. body: {sentFirstEvent: true},
  129. });
  130. MockApiClient.addMockResponse({
  131. url: '/organizations/org-slug/projects/',
  132. body: [project],
  133. });
  134. TagStore.init();
  135. props = {
  136. api,
  137. savedSearchLoading: false,
  138. savedSearches: [savedSearch],
  139. useOrgSavedSearches: true,
  140. selection: {
  141. projects: [parseInt(organization.projects[0].id, 10)],
  142. environments: [],
  143. datetime: {period: '14d'},
  144. },
  145. location: {query: {query: 'is:unresolved'}, search: 'query=is:unresolved'},
  146. params: {},
  147. organization,
  148. tags: tags.reduce((acc, tag) => {
  149. acc[tag.key] = tag;
  150. return acc;
  151. }),
  152. };
  153. });
  154. afterEach(function () {
  155. jest.clearAllMocks();
  156. MockApiClient.clearMockResponses();
  157. });
  158. describe('withStores and feature flags', function () {
  159. const defaultProps = {};
  160. let savedSearchesRequest;
  161. let recentSearchesRequest;
  162. let issuesRequest;
  163. beforeEach(function () {
  164. StreamGroup.mockClear();
  165. recentSearchesRequest = MockApiClient.addMockResponse({
  166. url: '/organizations/org-slug/recent-searches/',
  167. method: 'GET',
  168. body: [],
  169. });
  170. savedSearchesRequest = MockApiClient.addMockResponse({
  171. url: '/organizations/org-slug/searches/',
  172. body: [savedSearch],
  173. });
  174. issuesRequest = MockApiClient.addMockResponse({
  175. url: '/organizations/org-slug/issues/',
  176. body: [group],
  177. headers: {
  178. Link: DEFAULT_LINKS_HEADER,
  179. },
  180. });
  181. });
  182. it('loads group rows with default query (no pinned queries, async and no query in URL)', async function () {
  183. render(<IssueListWithStores {...routerProps} {...defaultProps} />, {
  184. context: routerContext,
  185. });
  186. // Loading saved searches
  187. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  188. expect(savedSearchesRequest).toHaveBeenCalledTimes(1);
  189. await userEvent.click(await screen.findByRole('textbox'));
  190. // auxillary requests being made
  191. expect(recentSearchesRequest).toHaveBeenCalledTimes(1);
  192. expect(fetchTagsRequest).toHaveBeenCalledTimes(1);
  193. expect(fetchMembersRequest).toHaveBeenCalledTimes(1);
  194. // primary /issues/ request
  195. expect(issuesRequest).toHaveBeenCalledWith(
  196. expect.anything(),
  197. expect.objectContaining({
  198. // Should be called with default query
  199. data: expect.stringContaining('is%3Aunresolved'),
  200. })
  201. );
  202. expect(screen.getByRole('textbox')).toHaveValue('is:unresolved ');
  203. expect(screen.getByRole('button', {name: /custom search/i})).toBeInTheDocument();
  204. });
  205. it('loads with query in URL and pinned queries', async function () {
  206. savedSearchesRequest = MockApiClient.addMockResponse({
  207. url: '/organizations/org-slug/searches/',
  208. body: [
  209. savedSearch,
  210. TestStubs.Search({
  211. id: '123',
  212. name: 'My Pinned Search',
  213. isPinned: true,
  214. query: 'is:resolved',
  215. }),
  216. ],
  217. });
  218. const location = {query: {query: 'level:foo'}};
  219. render(
  220. <IssueListWithStores {...merge({}, routerProps, {location})} {...defaultProps} />,
  221. {context: routerContext, router: {location}}
  222. );
  223. await waitFor(() => {
  224. // Main /issues/ request
  225. expect(issuesRequest).toHaveBeenCalledWith(
  226. expect.anything(),
  227. expect.objectContaining({
  228. // Should be called with default query
  229. data: expect.stringContaining('level%3Afoo'),
  230. })
  231. );
  232. });
  233. expect(screen.getByRole('textbox')).toHaveValue('level:foo ');
  234. // Tab shows "custom search"
  235. expect(screen.getByRole('button', {name: 'Custom Search'})).toBeInTheDocument();
  236. });
  237. it('loads with a pinned custom query', async function () {
  238. savedSearchesRequest = MockApiClient.addMockResponse({
  239. url: '/organizations/org-slug/searches/',
  240. body: [
  241. savedSearch,
  242. TestStubs.Search({
  243. id: '123',
  244. name: 'My Pinned Search',
  245. isPinned: true,
  246. isGlobal: false,
  247. query: 'is:resolved',
  248. }),
  249. ],
  250. });
  251. render(<IssueListWithStores {...routerProps} {...defaultProps} />, {
  252. context: routerContext,
  253. });
  254. await waitFor(() => {
  255. expect(issuesRequest).toHaveBeenCalledWith(
  256. expect.anything(),
  257. expect.objectContaining({
  258. // Should be called with default query
  259. data: expect.stringContaining('is%3Aresolved'),
  260. })
  261. );
  262. });
  263. expect(screen.getByRole('textbox')).toHaveValue('is:resolved ');
  264. // Organization saved search selector should have default saved search selected
  265. expect(screen.getByRole('button', {name: 'My Default Search'})).toBeInTheDocument();
  266. });
  267. it('loads with a saved query', async function () {
  268. savedSearchesRequest = MockApiClient.addMockResponse({
  269. url: '/organizations/org-slug/searches/',
  270. body: [
  271. TestStubs.Search({
  272. id: '123',
  273. name: 'Assigned to Me',
  274. isPinned: false,
  275. isGlobal: true,
  276. query: 'assigned:me',
  277. sort: 'priority',
  278. projectId: null,
  279. type: 0,
  280. }),
  281. ],
  282. });
  283. const localRouter = {params: {searchId: '123'}};
  284. render(
  285. <IssueListWithStores
  286. {...merge({}, routerProps, localRouter)}
  287. {...defaultProps}
  288. />,
  289. {context: routerContext, router: localRouter}
  290. );
  291. await waitFor(() => {
  292. expect(issuesRequest).toHaveBeenCalledWith(
  293. expect.anything(),
  294. expect.objectContaining({
  295. // Should be called with default query
  296. data:
  297. expect.stringContaining('assigned%3Ame') &&
  298. expect.stringContaining('sort=priority'),
  299. })
  300. );
  301. });
  302. expect(screen.getByRole('textbox')).toHaveValue('assigned:me ');
  303. // Organization saved search selector should have default saved search selected
  304. expect(screen.getByRole('button', {name: 'Assigned to Me'})).toBeInTheDocument();
  305. });
  306. it('loads with a query in URL', async function () {
  307. savedSearchesRequest = MockApiClient.addMockResponse({
  308. url: '/organizations/org-slug/searches/',
  309. body: [
  310. TestStubs.Search({
  311. id: '123',
  312. name: 'Assigned to Me',
  313. isPinned: false,
  314. isGlobal: true,
  315. query: 'assigned:me',
  316. projectId: null,
  317. type: 0,
  318. }),
  319. ],
  320. });
  321. const localRouter = {location: {query: {query: 'level:error'}}};
  322. render(
  323. <IssueListWithStores
  324. {...merge({}, routerProps, localRouter)}
  325. {...defaultProps}
  326. />,
  327. {context: routerContext, router: localRouter}
  328. );
  329. await waitFor(() => {
  330. expect(issuesRequest).toHaveBeenCalledWith(
  331. expect.anything(),
  332. expect.objectContaining({
  333. // Should be called with default query
  334. data: expect.stringContaining('level%3Aerror'),
  335. })
  336. );
  337. });
  338. expect(screen.getByRole('textbox')).toHaveValue('level:error ');
  339. // Organization saved search selector should have default saved search selected
  340. expect(screen.getByRole('button', {name: 'Custom Search'})).toBeInTheDocument();
  341. });
  342. it('loads with an empty query in URL', async function () {
  343. savedSearchesRequest = MockApiClient.addMockResponse({
  344. url: '/organizations/org-slug/searches/',
  345. body: [
  346. TestStubs.Search({
  347. id: '123',
  348. name: 'My Pinned Search',
  349. isPinned: true,
  350. isGlobal: false,
  351. query: 'is:resolved',
  352. }),
  353. ],
  354. });
  355. render(
  356. <IssueListWithStores
  357. {...merge({}, routerProps, {location: {query: {query: undefined}}})}
  358. {...defaultProps}
  359. />,
  360. {context: routerContext}
  361. );
  362. await waitFor(() => {
  363. expect(issuesRequest).toHaveBeenCalledWith(
  364. expect.anything(),
  365. expect.objectContaining({
  366. // Should be called with empty query
  367. data: expect.stringContaining(''),
  368. })
  369. );
  370. });
  371. expect(screen.getByRole('textbox')).toHaveValue('is:resolved ');
  372. // Organization saved search selector should have default saved search selected
  373. expect(screen.getByRole('button', {name: 'My Default Search'})).toBeInTheDocument();
  374. });
  375. it('1 search', async function () {
  376. const localSavedSearch = {...savedSearch, projectId: null};
  377. savedSearchesRequest = MockApiClient.addMockResponse({
  378. url: '/organizations/org-slug/searches/',
  379. body: [localSavedSearch],
  380. });
  381. render(<IssueListWithStores {...routerProps} {...defaultProps} />, {
  382. context: routerContext,
  383. });
  384. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  385. await userEvent.click(screen.getByRole('button', {name: /custom search/i}));
  386. await userEvent.click(screen.getByRole('button', {name: localSavedSearch.name}));
  387. expect(browserHistory.push).toHaveBeenLastCalledWith(
  388. expect.objectContaining({
  389. pathname: '/organizations/org-slug/issues/searches/789/',
  390. })
  391. );
  392. });
  393. it('clears a saved search when a custom one is entered', async function () {
  394. savedSearchesRequest = MockApiClient.addMockResponse({
  395. url: '/organizations/org-slug/searches/',
  396. body: [
  397. savedSearch,
  398. TestStubs.Search({
  399. id: '123',
  400. name: 'Pinned search',
  401. isPinned: true,
  402. isGlobal: false,
  403. query: 'is:resolved',
  404. }),
  405. ],
  406. });
  407. render(<IssueListWithStores {...routerProps} {...defaultProps} />, {
  408. context: routerContext,
  409. });
  410. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  411. await userEvent.clear(screen.getByRole('textbox'));
  412. await userEvent.type(screen.getByRole('textbox'), 'dogs{enter}');
  413. expect(browserHistory.push).toHaveBeenLastCalledWith(
  414. expect.objectContaining({
  415. pathname: '/organizations/org-slug/issues/',
  416. query: {
  417. environment: [],
  418. project: [],
  419. referrer: 'issue-list',
  420. query: 'dogs',
  421. statsPeriod: '14d',
  422. },
  423. })
  424. );
  425. });
  426. it('pins a custom query', async function () {
  427. const pinnedSearch = {
  428. id: '666',
  429. name: 'My Pinned Search',
  430. query: 'assigned:me level:fatal',
  431. sort: 'date',
  432. isPinned: true,
  433. visibility: SavedSearchVisibility.Organization,
  434. };
  435. savedSearchesRequest = MockApiClient.addMockResponse({
  436. url: '/organizations/org-slug/searches/',
  437. body: [savedSearch],
  438. });
  439. const {rerender} = render(
  440. <IssueListWithStores {...routerProps} {...defaultProps} />,
  441. {context: routerContext}
  442. );
  443. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  444. await userEvent.clear(screen.getByRole('textbox'));
  445. await userEvent.type(screen.getByRole('textbox'), 'assigned:me level:fatal{enter}');
  446. expect(browserHistory.push.mock.calls[0][0]).toEqual(
  447. expect.objectContaining({
  448. query: expect.objectContaining({
  449. query: 'assigned:me level:fatal',
  450. }),
  451. })
  452. );
  453. await tick();
  454. const routerWithQuery = {location: {query: {query: 'assigned:me level:fatal'}}};
  455. rerender(
  456. <IssueListWithStores
  457. {...merge({}, routerProps, routerWithQuery)}
  458. {...defaultProps}
  459. />,
  460. {context: routerContext, router: routerWithQuery}
  461. );
  462. expect(screen.getByRole('button', {name: 'Custom Search'})).toBeInTheDocument();
  463. MockApiClient.clearMockResponses();
  464. const createPin = MockApiClient.addMockResponse({
  465. url: '/organizations/org-slug/pinned-searches/',
  466. method: 'PUT',
  467. body: pinnedSearch,
  468. });
  469. MockApiClient.addMockResponse({
  470. url: '/organizations/org-slug/searches/',
  471. body: [savedSearch, pinnedSearch],
  472. });
  473. await userEvent.click(screen.getByLabelText(/Set as Default/i));
  474. await waitFor(() => {
  475. expect(createPin).toHaveBeenCalled();
  476. expect(browserHistory.replace).toHaveBeenLastCalledWith(
  477. expect.objectContaining({
  478. pathname: '/organizations/org-slug/issues/searches/666/',
  479. query: {
  480. referrer: 'search-bar',
  481. },
  482. search: '',
  483. })
  484. );
  485. });
  486. });
  487. it('unpins a custom query', async function () {
  488. const pinnedSearch = TestStubs.Search({
  489. id: '666',
  490. name: 'My Pinned Search',
  491. query: 'assigned:me level:fatal',
  492. sort: 'date',
  493. isPinned: true,
  494. visibility: SavedSearchVisibility.Organization,
  495. });
  496. savedSearchesRequest = MockApiClient.addMockResponse({
  497. url: '/organizations/org-slug/searches/',
  498. body: [pinnedSearch],
  499. });
  500. const deletePin = MockApiClient.addMockResponse({
  501. url: '/organizations/org-slug/pinned-searches/',
  502. method: 'DELETE',
  503. });
  504. const routerWithSavedSearch = {
  505. params: {searchId: pinnedSearch.id},
  506. };
  507. render(
  508. <IssueListWithStores
  509. {...merge({}, routerProps, routerWithSavedSearch)}
  510. {...defaultProps}
  511. />,
  512. {context: routerContext, router: routerWithSavedSearch}
  513. );
  514. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  515. expect(screen.getByRole('button', {name: 'My Default Search'})).toBeInTheDocument();
  516. await userEvent.click(screen.getByLabelText(/Remove Default/i));
  517. await waitFor(() => {
  518. expect(deletePin).toHaveBeenCalled();
  519. });
  520. await waitFor(() => {
  521. expect(browserHistory.replace).toHaveBeenLastCalledWith(
  522. expect.objectContaining({
  523. pathname: '/organizations/org-slug/issues/',
  524. })
  525. );
  526. });
  527. });
  528. it('pins a saved query', async function () {
  529. const assignedToMe = TestStubs.Search({
  530. id: '234',
  531. name: 'Assigned to Me',
  532. isPinned: false,
  533. isGlobal: true,
  534. query: 'assigned:me',
  535. sort: 'date',
  536. projectId: null,
  537. type: 0,
  538. });
  539. savedSearchesRequest = MockApiClient.addMockResponse({
  540. url: '/organizations/org-slug/searches/',
  541. body: [savedSearch, assignedToMe],
  542. });
  543. const createPin = MockApiClient.addMockResponse({
  544. url: '/organizations/org-slug/pinned-searches/',
  545. method: 'PUT',
  546. body: {
  547. ...savedSearch,
  548. isPinned: true,
  549. },
  550. });
  551. const routerWithSavedSearch = {params: {searchId: '789'}};
  552. render(
  553. <IssueListWithStores
  554. {...merge({}, routerProps, routerWithSavedSearch)}
  555. {...defaultProps}
  556. />,
  557. {context: routerContext, router: routerWithSavedSearch}
  558. );
  559. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  560. expect(screen.getByRole('button', {name: savedSearch.name})).toBeInTheDocument();
  561. await userEvent.click(screen.getByLabelText(/set as default/i));
  562. await waitFor(() => {
  563. expect(createPin).toHaveBeenCalled();
  564. expect(browserHistory.replace).toHaveBeenLastCalledWith(
  565. expect.objectContaining({
  566. pathname: '/organizations/org-slug/issues/searches/789/',
  567. })
  568. );
  569. });
  570. });
  571. it('pinning search should keep project selected', async function () {
  572. savedSearchesRequest = MockApiClient.addMockResponse({
  573. url: '/organizations/org-slug/searches/',
  574. body: [savedSearch],
  575. });
  576. const {routerContext: newRouterContext, router: newRouter} = initializeOrg({
  577. router: {
  578. location: {
  579. query: {
  580. project: ['123'],
  581. environment: ['prod'],
  582. query: 'assigned:me level:fatal',
  583. },
  584. },
  585. },
  586. });
  587. render(
  588. <IssueListWithStores
  589. {...newRouter}
  590. {...defaultProps}
  591. selection={{
  592. projects: ['123'],
  593. environments: ['prod'],
  594. datetime: {},
  595. }}
  596. />,
  597. {context: newRouterContext}
  598. );
  599. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  600. const createPin = MockApiClient.addMockResponse({
  601. url: '/organizations/org-slug/pinned-searches/',
  602. method: 'PUT',
  603. body: {
  604. ...savedSearch,
  605. id: '666',
  606. name: 'My Pinned Search',
  607. query: 'assigned:me level:fatal',
  608. sort: 'date',
  609. isPinned: true,
  610. },
  611. });
  612. await userEvent.click(screen.getByLabelText(/set as default/i));
  613. await waitFor(() => {
  614. expect(createPin).toHaveBeenCalled();
  615. expect(browserHistory.replace).toHaveBeenLastCalledWith(
  616. expect.objectContaining({
  617. pathname: '/organizations/org-slug/issues/searches/666/',
  618. query: expect.objectContaining({
  619. project: ['123'],
  620. environment: ['prod'],
  621. query: 'assigned:me level:fatal',
  622. referrer: 'search-bar',
  623. }),
  624. })
  625. );
  626. });
  627. });
  628. it('unpinning search should keep project selected', async function () {
  629. const localSavedSearch = {
  630. ...savedSearch,
  631. id: '666',
  632. isPinned: true,
  633. query: 'assigned:me level:fatal',
  634. };
  635. savedSearchesRequest = MockApiClient.addMockResponse({
  636. url: '/organizations/org-slug/searches/',
  637. body: [localSavedSearch],
  638. });
  639. const deletePin = MockApiClient.addMockResponse({
  640. url: '/organizations/org-slug/pinned-searches/',
  641. method: 'DELETE',
  642. });
  643. const {routerContext: newRouterContext, router: newRouter} = initializeOrg(
  644. merge({}, router, {
  645. router: {
  646. location: {
  647. query: {
  648. project: ['123'],
  649. environment: ['prod'],
  650. query: 'assigned:me level:fatal',
  651. },
  652. },
  653. params: {searchId: '666'},
  654. },
  655. })
  656. );
  657. render(
  658. <IssueListWithStores
  659. {...newRouter}
  660. {...defaultProps}
  661. selection={{
  662. projects: ['123'],
  663. environments: ['prod'],
  664. datetime: {},
  665. }}
  666. savedSearch={localSavedSearch}
  667. />,
  668. {context: newRouterContext}
  669. );
  670. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  671. await userEvent.click(screen.getByLabelText(/Remove Default/i));
  672. await waitFor(() => {
  673. expect(deletePin).toHaveBeenCalled();
  674. expect(browserHistory.replace).toHaveBeenLastCalledWith(
  675. expect.objectContaining({
  676. pathname: '/organizations/org-slug/issues/',
  677. query: expect.objectContaining({
  678. project: ['123'],
  679. environment: ['prod'],
  680. query: 'assigned:me level:fatal',
  681. referrer: 'search-bar',
  682. }),
  683. })
  684. );
  685. });
  686. });
  687. it('does not allow pagination to "previous" while on first page and resets cursors when navigating back to initial page', async function () {
  688. const {rerender} = render(
  689. <IssueListWithStores {...routerProps} {...defaultProps} />,
  690. {
  691. context: routerContext,
  692. }
  693. );
  694. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  695. expect(screen.getByRole('button', {name: 'Previous'})).toBeDisabled();
  696. issuesRequest = MockApiClient.addMockResponse({
  697. url: '/organizations/org-slug/issues/',
  698. body: [group],
  699. headers: {
  700. Link: '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575000:0:0>; rel="previous"; results="true"; cursor="1443575000:0:1", <http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443574000:0:0>; rel="next"; results="true"; cursor="1443574000:0:0"',
  701. },
  702. });
  703. await userEvent.click(screen.getByRole('button', {name: 'Next'}));
  704. let pushArgs = {
  705. pathname: '/organizations/org-slug/issues/',
  706. query: {
  707. cursor: '1443575000:0:0',
  708. page: 1,
  709. environment: [],
  710. project: [],
  711. query: 'is:unresolved',
  712. statsPeriod: '14d',
  713. referrer: 'issue-list',
  714. },
  715. };
  716. await waitFor(() => {
  717. expect(browserHistory.push).toHaveBeenLastCalledWith(pushArgs);
  718. });
  719. rerender(
  720. <IssueListWithStores
  721. {...merge({}, routerProps, {location: pushArgs})}
  722. {...defaultProps}
  723. />
  724. );
  725. expect(screen.getByRole('button', {name: 'Previous'})).toBeEnabled();
  726. // Click next again
  727. await userEvent.click(screen.getByRole('button', {name: 'Next'}));
  728. pushArgs = {
  729. pathname: '/organizations/org-slug/issues/',
  730. query: {
  731. cursor: '1443574000:0:0',
  732. page: 2,
  733. environment: [],
  734. project: [],
  735. query: 'is:unresolved',
  736. statsPeriod: '14d',
  737. referrer: 'issue-list',
  738. },
  739. };
  740. await waitFor(() => {
  741. expect(browserHistory.push).toHaveBeenLastCalledWith(pushArgs);
  742. });
  743. rerender(
  744. <IssueListWithStores
  745. {...merge({}, routerProps, {location: pushArgs})}
  746. {...defaultProps}
  747. />
  748. );
  749. // Click previous
  750. await userEvent.click(screen.getByRole('button', {name: 'Previous'}));
  751. pushArgs = {
  752. pathname: '/organizations/org-slug/issues/',
  753. query: {
  754. cursor: '1443575000:0:1',
  755. page: 1,
  756. environment: [],
  757. project: [],
  758. query: 'is:unresolved',
  759. statsPeriod: '14d',
  760. referrer: 'issue-list',
  761. },
  762. };
  763. await waitFor(() => {
  764. expect(browserHistory.push).toHaveBeenLastCalledWith(pushArgs);
  765. });
  766. rerender(
  767. <IssueListWithStores
  768. {...merge({}, routerProps, {location: pushArgs})}
  769. {...defaultProps}
  770. />
  771. );
  772. // Click previous back to initial page
  773. await userEvent.click(screen.getByRole('button', {name: 'Previous'}));
  774. await waitFor(() => {
  775. // cursor is undefined because "prev" cursor is === initial "next" cursor
  776. expect(browserHistory.push).toHaveBeenLastCalledWith({
  777. pathname: '/organizations/org-slug/issues/',
  778. query: {
  779. cursor: undefined,
  780. environment: [],
  781. page: undefined,
  782. project: [],
  783. query: 'is:unresolved',
  784. statsPeriod: '14d',
  785. referrer: 'issue-list',
  786. },
  787. });
  788. });
  789. });
  790. });
  791. describe('transitionTo', function () {
  792. it('pushes to history when query is updated', async function () {
  793. MockApiClient.addMockResponse({
  794. url: '/organizations/org-slug/issues/',
  795. body: [],
  796. headers: {
  797. Link: DEFAULT_LINKS_HEADER,
  798. },
  799. });
  800. render(<IssueListOverview {...props} />, {
  801. context: routerContext,
  802. });
  803. await userEvent.clear(screen.getByRole('textbox'));
  804. await userEvent.type(screen.getByRole('textbox'), 'is:ignored{enter}');
  805. expect(browserHistory.push).toHaveBeenCalledWith({
  806. pathname: '/organizations/org-slug/issues/',
  807. query: {
  808. environment: [],
  809. project: [parseInt(project.id, 10)],
  810. query: 'is:ignored',
  811. statsPeriod: '14d',
  812. referrer: 'issue-list',
  813. },
  814. });
  815. });
  816. });
  817. it('fetches tags and members', async function () {
  818. render(<IssueListOverview {...routerProps} {...props} />, {context: routerContext});
  819. await waitFor(() => {
  820. expect(fetchTagsRequest).toHaveBeenCalled();
  821. expect(fetchMembersRequest).toHaveBeenCalled();
  822. });
  823. });
  824. describe('componentDidUpdate fetching groups', function () {
  825. let fetchDataMock;
  826. beforeEach(function () {
  827. fetchDataMock = MockApiClient.addMockResponse({
  828. url: '/organizations/org-slug/issues/',
  829. body: [group],
  830. headers: {
  831. Link: DEFAULT_LINKS_HEADER,
  832. },
  833. });
  834. fetchDataMock.mockReset();
  835. });
  836. it('fetches data on selection change', function () {
  837. const {rerender} = render(<IssueListOverview {...routerProps} {...props} />, {
  838. context: routerContext,
  839. });
  840. rerender(
  841. <IssueListOverview
  842. {...routerProps}
  843. {...props}
  844. selection={{projects: [99], environments: [], datetime: {period: '24h'}}}
  845. />
  846. );
  847. expect(fetchDataMock).toHaveBeenCalled();
  848. });
  849. it('fetches data on savedSearch change', function () {
  850. const {rerender} = render(<IssueListOverview {...routerProps} {...props} />, {
  851. context: routerContext,
  852. });
  853. rerender(
  854. <IssueListOverview
  855. {...routerProps}
  856. {...props}
  857. savedSearch={{id: '1', query: 'is:resolved'}}
  858. />
  859. );
  860. expect(fetchDataMock).toHaveBeenCalled();
  861. });
  862. it('uses correct statsPeriod when fetching issues list and no datetime given', function () {
  863. const {rerender} = render(<IssueListOverview {...routerProps} {...props} />, {
  864. context: routerContext,
  865. });
  866. const selection = {projects: [99], environments: [], datetime: {}};
  867. rerender(<IssueListOverview {...routerProps} {...props} selection={selection} />);
  868. expect(fetchDataMock).toHaveBeenLastCalledWith(
  869. '/organizations/org-slug/issues/',
  870. expect.objectContaining({
  871. data: 'collapse=stats&expand=owners&expand=inbox&limit=25&project=99&query=is%3Aunresolved&shortIdLookup=1&statsPeriod=14d',
  872. })
  873. );
  874. });
  875. });
  876. describe('componentDidUpdate fetching members', function () {
  877. it('fetches memberlist and tags list on project change', function () {
  878. const {rerender} = render(<IssueListOverview {...routerProps} {...props} />, {
  879. context: routerContext,
  880. });
  881. // Called during componentDidMount
  882. expect(fetchMembersRequest).toHaveBeenCalledTimes(1);
  883. expect(fetchTagsRequest).toHaveBeenCalledTimes(1);
  884. const selection = {
  885. projects: [99],
  886. environments: [],
  887. datetime: {period: '24h'},
  888. };
  889. rerender(<IssueListOverview {...routerProps} {...props} selection={selection} />);
  890. expect(fetchMembersRequest).toHaveBeenCalledTimes(2);
  891. expect(fetchTagsRequest).toHaveBeenCalledTimes(2);
  892. });
  893. });
  894. describe('render states', function () {
  895. it('displays the loading icon when saved searches are loading', function () {
  896. render(<IssueListOverview {...routerProps} {...props} savedSearchLoading />, {
  897. context: routerContext,
  898. });
  899. expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
  900. });
  901. it('displays an error when issues fail to load', async function () {
  902. MockApiClient.addMockResponse({
  903. url: '/organizations/org-slug/issues/',
  904. status: 500,
  905. statusCode: 500,
  906. });
  907. render(<IssueListOverview {...routerProps} {...props} />, {
  908. context: routerContext,
  909. });
  910. expect(await screen.findByTestId('loading-error')).toBeInTheDocument();
  911. });
  912. it('displays congrats robots animation with only is:unresolved query', async function () {
  913. MockApiClient.addMockResponse({
  914. url: '/organizations/org-slug/issues/',
  915. body: [],
  916. headers: {
  917. Link: DEFAULT_LINKS_HEADER,
  918. },
  919. });
  920. render(<IssueListOverview {...routerProps} {...props} />, {context: routerContext});
  921. expect(
  922. await screen.findByText(/We couldn't find any issues that matched your filters/i)
  923. ).toBeInTheDocument();
  924. });
  925. it('displays an empty resultset with a non-default query', async function () {
  926. MockApiClient.addMockResponse({
  927. url: '/organizations/org-slug/issues/',
  928. body: [],
  929. headers: {
  930. Link: DEFAULT_LINKS_HEADER,
  931. },
  932. });
  933. render(<IssueListOverview {...routerProps} {...props} />, {context: routerContext});
  934. await userEvent.type(screen.getByRole('textbox'), ' level:error{enter}');
  935. expect(
  936. await screen.findByText(/We couldn't find any issues that matched your filters/i)
  937. ).toBeInTheDocument();
  938. });
  939. });
  940. describe('Error Robot', function () {
  941. const createWrapper = async moreProps => {
  942. MockApiClient.addMockResponse({
  943. url: '/organizations/org-slug/issues/',
  944. body: [],
  945. headers: {
  946. Link: DEFAULT_LINKS_HEADER,
  947. },
  948. });
  949. const defaultProps = {
  950. ...props,
  951. useOrgSavedSearches: true,
  952. selection: {
  953. projects: [],
  954. environments: [],
  955. datetime: {period: '14d'},
  956. },
  957. ...merge({}, routerProps, {
  958. params: {},
  959. location: {query: {query: 'is:unresolved'}, search: 'query=is:unresolved'},
  960. }),
  961. organization: TestStubs.Organization({
  962. projects: [],
  963. }),
  964. ...moreProps,
  965. };
  966. render(<IssueListOverview {...defaultProps} />, {
  967. context: routerContext,
  968. });
  969. await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
  970. };
  971. it('displays when no projects selected and all projects user is member of, async does not have first event', async function () {
  972. const projects = [
  973. TestStubs.Project({
  974. id: '1',
  975. slug: 'foo',
  976. isMember: true,
  977. firstEvent: false,
  978. }),
  979. TestStubs.Project({
  980. id: '2',
  981. slug: 'bar',
  982. isMember: true,
  983. firstEvent: false,
  984. }),
  985. TestStubs.Project({
  986. id: '3',
  987. slug: 'baz',
  988. isMember: true,
  989. firstEvent: false,
  990. }),
  991. ];
  992. MockApiClient.addMockResponse({
  993. url: '/organizations/org-slug/sent-first-event/',
  994. query: {
  995. is_member: true,
  996. },
  997. body: {sentFirstEvent: false},
  998. });
  999. MockApiClient.addMockResponse({
  1000. url: '/organizations/org-slug/projects/',
  1001. body: projects,
  1002. });
  1003. MockApiClient.addMockResponse({
  1004. url: '/projects/org-slug/foo/issues/',
  1005. body: [],
  1006. });
  1007. await createWrapper({
  1008. organization: TestStubs.Organization({
  1009. projects,
  1010. }),
  1011. });
  1012. expect(screen.getByTestId('awaiting-events')).toBeInTheDocument();
  1013. });
  1014. it('does not display when no projects selected and any projects have a first event', async function () {
  1015. const projects = [
  1016. TestStubs.Project({
  1017. id: '1',
  1018. slug: 'foo',
  1019. isMember: true,
  1020. firstEvent: false,
  1021. }),
  1022. TestStubs.Project({
  1023. id: '2',
  1024. slug: 'bar',
  1025. isMember: true,
  1026. firstEvent: true,
  1027. }),
  1028. TestStubs.Project({
  1029. id: '3',
  1030. slug: 'baz',
  1031. isMember: true,
  1032. firstEvent: false,
  1033. }),
  1034. ];
  1035. MockApiClient.addMockResponse({
  1036. url: '/organizations/org-slug/sent-first-event/',
  1037. query: {
  1038. is_member: true,
  1039. },
  1040. body: {sentFirstEvent: true},
  1041. });
  1042. MockApiClient.addMockResponse({
  1043. url: '/organizations/org-slug/projects/',
  1044. body: projects,
  1045. });
  1046. await createWrapper({
  1047. organization: TestStubs.Organization({
  1048. projects,
  1049. }),
  1050. });
  1051. expect(screen.queryByTestId('awaiting-events')).not.toBeInTheDocument();
  1052. });
  1053. it('displays when all selected projects do not have first event', async function () {
  1054. const projects = [
  1055. TestStubs.Project({
  1056. id: '1',
  1057. slug: 'foo',
  1058. isMember: true,
  1059. firstEvent: false,
  1060. }),
  1061. TestStubs.Project({
  1062. id: '2',
  1063. slug: 'bar',
  1064. isMember: true,
  1065. firstEvent: false,
  1066. }),
  1067. TestStubs.Project({
  1068. id: '3',
  1069. slug: 'baz',
  1070. isMember: true,
  1071. firstEvent: false,
  1072. }),
  1073. ];
  1074. MockApiClient.addMockResponse({
  1075. url: '/organizations/org-slug/sent-first-event/',
  1076. query: {
  1077. project: [1, 2],
  1078. },
  1079. body: {sentFirstEvent: false},
  1080. });
  1081. MockApiClient.addMockResponse({
  1082. url: '/organizations/org-slug/projects/',
  1083. body: projects,
  1084. });
  1085. MockApiClient.addMockResponse({
  1086. url: '/projects/org-slug/foo/issues/',
  1087. body: [],
  1088. });
  1089. await createWrapper({
  1090. selection: {
  1091. projects: [1, 2],
  1092. environments: [],
  1093. datetime: {period: '14d'},
  1094. },
  1095. organization: TestStubs.Organization({
  1096. projects,
  1097. }),
  1098. });
  1099. expect(await screen.findByTestId('awaiting-events')).toBeInTheDocument();
  1100. });
  1101. it('does not display when any selected projects have first event', async function () {
  1102. const projects = [
  1103. TestStubs.Project({
  1104. id: '1',
  1105. slug: 'foo',
  1106. isMember: true,
  1107. firstEvent: false,
  1108. }),
  1109. TestStubs.Project({
  1110. id: '2',
  1111. slug: 'bar',
  1112. isMember: true,
  1113. firstEvent: true,
  1114. }),
  1115. TestStubs.Project({
  1116. id: '3',
  1117. slug: 'baz',
  1118. isMember: true,
  1119. firstEvent: true,
  1120. }),
  1121. ];
  1122. MockApiClient.addMockResponse({
  1123. url: '/organizations/org-slug/sent-first-event/',
  1124. query: {
  1125. project: [1, 2],
  1126. },
  1127. body: {sentFirstEvent: true},
  1128. });
  1129. MockApiClient.addMockResponse({
  1130. url: '/organizations/org-slug/projects/',
  1131. body: projects,
  1132. });
  1133. await createWrapper({
  1134. selection: {
  1135. projects: [1, 2],
  1136. environments: [],
  1137. datetime: {period: '14d'},
  1138. },
  1139. organization: TestStubs.Organization({
  1140. projects,
  1141. }),
  1142. });
  1143. expect(screen.queryByTestId('awaiting-events')).not.toBeInTheDocument();
  1144. });
  1145. });
  1146. it('displays a count that represents the current page', function () {
  1147. MockApiClient.addMockResponse({
  1148. url: '/organizations/org-slug/issues/',
  1149. body: [...new Array(25)].map((_, i) => ({id: i})),
  1150. headers: {
  1151. Link: DEFAULT_LINKS_HEADER,
  1152. 'X-Hits': 500,
  1153. 'X-Max-Hits': 1000,
  1154. },
  1155. });
  1156. parseLinkHeaderSpy.mockReturnValue({
  1157. next: {
  1158. results: true,
  1159. },
  1160. previous: {
  1161. results: false,
  1162. },
  1163. });
  1164. props = {
  1165. ...props,
  1166. location: {
  1167. query: {
  1168. cursor: 'some cursor',
  1169. page: 0,
  1170. },
  1171. },
  1172. };
  1173. const {routerContext: newRouterContext} = initializeOrg();
  1174. render(<IssueListOverview {...props} />, {
  1175. context: newRouterContext,
  1176. });
  1177. expect(
  1178. screen.getByText(textWithMarkupMatcher('Showing 25 of 500 issues'))
  1179. ).toBeInTheDocument();
  1180. parseLinkHeaderSpy.mockReturnValue({
  1181. next: {
  1182. results: true,
  1183. },
  1184. previous: {
  1185. results: true,
  1186. },
  1187. });
  1188. expect(
  1189. screen.getByText(textWithMarkupMatcher('Showing 25 of 500 issues'))
  1190. ).toBeInTheDocument();
  1191. });
  1192. describe('project low priority queue alert', function () {
  1193. const {routerContext: newRouterContext} = initializeOrg();
  1194. beforeEach(function () {
  1195. act(() => ProjectsStore.reset());
  1196. });
  1197. it('does not render event processing alert', function () {
  1198. act(() => ProjectsStore.loadInitialData([project]));
  1199. render(<IssueListOverview {...props} />, {
  1200. context: newRouterContext,
  1201. });
  1202. expect(screen.queryByText(/event processing/i)).not.toBeInTheDocument();
  1203. });
  1204. describe('renders alert', function () {
  1205. it('for one project', function () {
  1206. act(() =>
  1207. ProjectsStore.loadInitialData([
  1208. {...project, eventProcessing: {symbolicationDegraded: true}},
  1209. ])
  1210. );
  1211. render(<IssueListOverview {...props} />, {
  1212. context: routerContext,
  1213. });
  1214. expect(
  1215. screen.getByText(/Event Processing for this project is currently degraded/i)
  1216. ).toBeInTheDocument();
  1217. });
  1218. it('for multiple projects', function () {
  1219. const projectBar = TestStubs.ProjectDetails({
  1220. id: '3560',
  1221. name: 'Bar Project',
  1222. slug: 'project-slug-bar',
  1223. });
  1224. act(() =>
  1225. ProjectsStore.loadInitialData([
  1226. {
  1227. ...project,
  1228. slug: 'project-slug',
  1229. eventProcessing: {symbolicationDegraded: true},
  1230. },
  1231. {
  1232. ...projectBar,
  1233. slug: 'project-slug-bar',
  1234. eventProcessing: {symbolicationDegraded: true},
  1235. },
  1236. ])
  1237. );
  1238. render(
  1239. <IssueListOverview
  1240. {...props}
  1241. selection={{
  1242. ...props.selection,
  1243. projects: [Number(project.id), Number(projectBar.id)],
  1244. }}
  1245. />,
  1246. {
  1247. context: newRouterContext,
  1248. }
  1249. );
  1250. expect(
  1251. screen.getByText(
  1252. textWithMarkupMatcher(
  1253. 'Event Processing for the project-slug, project-slug-bar projects is currently degraded.'
  1254. )
  1255. )
  1256. ).toBeInTheDocument();
  1257. });
  1258. });
  1259. });
  1260. });