overviewFc.spec.tsx 41 KB

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