index.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. import {Fragment} from 'react';
  2. import {
  3. fireEvent,
  4. render,
  5. screen,
  6. userEvent,
  7. within,
  8. } from 'sentry-test/reactTestingLibrary';
  9. import GlobalModal from 'sentry/components/globalModal';
  10. import GroupStore from 'sentry/stores/groupStore';
  11. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  12. import {IssueCategory} from 'sentry/types';
  13. import * as analytics from 'sentry/utils/analytics';
  14. import {IssueListActions} from 'sentry/views/issueList/actions';
  15. const organization = TestStubs.Organization();
  16. const defaultProps = {
  17. allResultsVisible: false,
  18. query: '',
  19. queryCount: 15,
  20. projectId: 'project-slug',
  21. selection: {
  22. projects: [1],
  23. environments: [],
  24. datetime: {start: null, end: null, period: null, utc: true},
  25. },
  26. groupIds: ['1', '2', '3'],
  27. onRealtimeChange: jest.fn(),
  28. onSelectStatsPeriod: jest.fn(),
  29. realtimeActive: false,
  30. statsPeriod: '24h',
  31. onDelete: jest.fn(),
  32. displayReprocessingActions: false,
  33. onSortChange: jest.fn(),
  34. sort: '',
  35. };
  36. function WrappedComponent(props) {
  37. return (
  38. <Fragment>
  39. <GlobalModal />
  40. <IssueListActions {...defaultProps} {...props} />
  41. </Fragment>
  42. );
  43. }
  44. describe('IssueListActions', function () {
  45. afterEach(() => {
  46. jest.restoreAllMocks();
  47. });
  48. beforeEach(() => {
  49. GroupStore.reset();
  50. SelectedGroupStore.reset();
  51. SelectedGroupStore.add(['1', '2', '3']);
  52. MockApiClient.addMockResponse({
  53. url: `/organizations/${organization.slug}/projects/`,
  54. body: [TestStubs.Project({id: 1})],
  55. });
  56. });
  57. describe('Bulk', function () {
  58. describe('Total results greater than bulk limit', function () {
  59. it('after checking "Select all" checkbox, displays bulk select message', async function () {
  60. render(<WrappedComponent queryCount={1500} />);
  61. await userEvent.click(screen.getByRole('checkbox'));
  62. expect(screen.getByTestId('issue-list-select-all-notice')).toSnapshot();
  63. });
  64. it('can bulk select', async function () {
  65. render(<WrappedComponent queryCount={1500} />);
  66. await userEvent.click(screen.getByRole('checkbox'));
  67. await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
  68. expect(screen.getByTestId('issue-list-select-all-notice')).toSnapshot();
  69. });
  70. it('bulk resolves', async function () {
  71. const apiMock = MockApiClient.addMockResponse({
  72. url: '/organizations/org-slug/issues/',
  73. method: 'PUT',
  74. });
  75. render(<WrappedComponent queryCount={1500} />);
  76. await userEvent.click(screen.getByRole('checkbox'));
  77. await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
  78. await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
  79. await screen.findByRole('dialog');
  80. await userEvent.click(screen.getByRole('button', {name: 'Bulk resolve issues'}));
  81. expect(apiMock).toHaveBeenCalledWith(
  82. expect.anything(),
  83. expect.objectContaining({
  84. query: {
  85. project: [1],
  86. },
  87. data: {status: 'resolved', statusDetails: {}, substatus: null},
  88. })
  89. );
  90. });
  91. });
  92. describe('Total results less than bulk limit', function () {
  93. it('after checking "Select all" checkbox, displays bulk select message', async function () {
  94. render(<WrappedComponent queryCount={15} />);
  95. await userEvent.click(screen.getByRole('checkbox'));
  96. expect(screen.getByTestId('issue-list-select-all-notice')).toSnapshot();
  97. });
  98. it('can bulk select', async function () {
  99. render(<WrappedComponent queryCount={15} />);
  100. await userEvent.click(screen.getByRole('checkbox'));
  101. await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
  102. expect(screen.getByTestId('issue-list-select-all-notice')).toSnapshot();
  103. });
  104. it('bulk resolves', async function () {
  105. const apiMock = MockApiClient.addMockResponse({
  106. url: '/organizations/org-slug/issues/',
  107. method: 'PUT',
  108. });
  109. render(<WrappedComponent queryCount={15} />);
  110. await userEvent.click(screen.getByRole('checkbox'));
  111. await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
  112. await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
  113. const modal = screen.getByRole('dialog');
  114. expect(modal).toSnapshot();
  115. await userEvent.click(
  116. within(modal).getByRole('button', {name: 'Bulk resolve issues'})
  117. );
  118. expect(apiMock).toHaveBeenCalledWith(
  119. expect.anything(),
  120. expect.objectContaining({
  121. query: {
  122. project: [1],
  123. },
  124. data: {status: 'resolved', statusDetails: {}, substatus: null},
  125. })
  126. );
  127. });
  128. });
  129. describe('Selected on page', function () {
  130. it('resolves selected items', async function () {
  131. const apiMock = MockApiClient.addMockResponse({
  132. url: '/organizations/org-slug/issues/',
  133. method: 'PUT',
  134. });
  135. jest.spyOn(SelectedGroupStore, 'getSelectedIds').mockReturnValue(new Set(['1']));
  136. render(<WrappedComponent groupIds={['1', '2', '3', '6', '9']} />);
  137. const resolveButton = screen.getByRole('button', {name: 'Resolve'});
  138. expect(resolveButton).toBeEnabled();
  139. await userEvent.click(resolveButton);
  140. expect(apiMock).toHaveBeenCalledWith(
  141. expect.anything(),
  142. expect.objectContaining({
  143. query: {
  144. id: ['1'],
  145. project: [1],
  146. },
  147. data: {status: 'resolved', statusDetails: {}, substatus: null},
  148. })
  149. );
  150. });
  151. it('can ignore selected items (custom)', async function () {
  152. const analyticsSpy = jest.spyOn(analytics, 'trackAnalytics');
  153. const apiMock = MockApiClient.addMockResponse({
  154. url: '/organizations/org-slug/issues/',
  155. method: 'PUT',
  156. });
  157. jest.spyOn(SelectedGroupStore, 'getSelectedIds').mockReturnValue(new Set(['1']));
  158. render(<WrappedComponent {...defaultProps} />);
  159. await userEvent.click(screen.getByRole('button', {name: 'Ignore options'}));
  160. fireEvent.click(screen.getByText(/Until this affects an additional/));
  161. await screen.findByTestId('until-affect-custom');
  162. await userEvent.click(screen.getByTestId('until-affect-custom'));
  163. const modal = screen.getByRole('dialog');
  164. await userEvent.clear(
  165. within(modal).getByRole('spinbutton', {name: 'Number of users'})
  166. );
  167. await userEvent.type(
  168. within(modal).getByRole('spinbutton', {name: 'Number of users'}),
  169. '300'
  170. );
  171. await userEvent.click(within(modal).getByRole('textbox'));
  172. await userEvent.click(within(modal).getByText('per week'));
  173. await userEvent.click(within(modal).getByRole('button', {name: 'Ignore'}));
  174. expect(apiMock).toHaveBeenCalledWith(
  175. expect.anything(),
  176. expect.objectContaining({
  177. query: {
  178. id: ['1'],
  179. project: [1],
  180. },
  181. data: {
  182. status: 'ignored',
  183. statusDetails: {
  184. ignoreUserCount: 300,
  185. ignoreUserWindow: 10080,
  186. },
  187. substatus: 'archived_until_condition_met',
  188. },
  189. })
  190. );
  191. expect(analyticsSpy).toHaveBeenCalledWith(
  192. 'issues_stream.archived',
  193. expect.objectContaining({
  194. action_status_details: 'ignoreUserCount',
  195. })
  196. );
  197. });
  198. });
  199. });
  200. it('can archive an issue until escalating', async () => {
  201. const analyticsSpy = jest.spyOn(analytics, 'trackAnalytics');
  202. const org_escalating = {...organization, features: ['escalating-issues']};
  203. const apiMock = MockApiClient.addMockResponse({
  204. url: `/organizations/${org_escalating.slug}/issues/`,
  205. method: 'PUT',
  206. });
  207. jest.spyOn(SelectedGroupStore, 'getSelectedIds').mockReturnValue(new Set(['1']));
  208. render(<WrappedComponent {...defaultProps} />, {organization: org_escalating});
  209. await userEvent.click(screen.getByRole('button', {name: 'Archive'}));
  210. expect(apiMock).toHaveBeenCalledWith(
  211. expect.anything(),
  212. expect.objectContaining({
  213. query: {
  214. id: ['1'],
  215. project: [1],
  216. },
  217. data: {
  218. status: 'ignored',
  219. statusDetails: {},
  220. substatus: 'archived_until_escalating',
  221. },
  222. })
  223. );
  224. expect(analyticsSpy).toHaveBeenCalledWith(
  225. 'issues_stream.archived',
  226. expect.objectContaining({
  227. action_substatus: 'archived_until_escalating',
  228. })
  229. );
  230. });
  231. it('can unarchive an issue when the query contains is:archived', async () => {
  232. const org_escalating = {...organization, features: ['escalating-issues']};
  233. const apiMock = MockApiClient.addMockResponse({
  234. url: `/organizations/${org_escalating.slug}/issues/`,
  235. method: 'PUT',
  236. });
  237. jest.spyOn(SelectedGroupStore, 'getSelectedIds').mockReturnValue(new Set(['1']));
  238. render(<WrappedComponent {...defaultProps} query="is:archived" />, {
  239. organization: org_escalating,
  240. });
  241. await userEvent.click(screen.getByRole('button', {name: 'Unarchive'}));
  242. expect(apiMock).toHaveBeenCalledWith(
  243. expect.anything(),
  244. expect.objectContaining({
  245. query: expect.objectContaining({id: ['1'], project: [1]}),
  246. data: {status: 'unresolved'},
  247. })
  248. );
  249. });
  250. it('can resolve but not merge issues from different projects', function () {
  251. jest
  252. .spyOn(SelectedGroupStore, 'getSelectedIds')
  253. .mockImplementation(() => new Set(['1', '2', '3']));
  254. jest.spyOn(GroupStore, 'get').mockImplementation(id => {
  255. switch (id) {
  256. case '1':
  257. return TestStubs.Group({project: TestStubs.Project({slug: 'project-1'})});
  258. default:
  259. return TestStubs.Group({project: TestStubs.Project({slug: 'project-2'})});
  260. }
  261. });
  262. render(<WrappedComponent />);
  263. // Can resolve but not merge issues from multiple projects
  264. expect(screen.getByRole('button', {name: 'Resolve'})).toBeEnabled();
  265. expect(screen.getByRole('button', {name: 'Merge Selected Issues'})).toBeDisabled();
  266. });
  267. describe('mark reviewed', function () {
  268. it('acknowledges group', async function () {
  269. const mockOnMarkReviewed = jest.fn();
  270. MockApiClient.addMockResponse({
  271. url: '/organizations/org-slug/issues/',
  272. method: 'PUT',
  273. });
  274. jest
  275. .spyOn(SelectedGroupStore, 'getSelectedIds')
  276. .mockImplementation(() => new Set(['1', '2', '3']));
  277. jest.spyOn(GroupStore, 'get').mockImplementation(id => {
  278. return TestStubs.Group({
  279. id,
  280. inbox: {
  281. date_added: '2020-11-24T13:17:42.248751Z',
  282. reason: 0,
  283. reason_details: null,
  284. },
  285. });
  286. });
  287. render(<WrappedComponent onMarkReviewed={mockOnMarkReviewed} />);
  288. const reviewButton = screen.getByRole('button', {name: 'Mark Reviewed'});
  289. expect(reviewButton).toBeEnabled();
  290. await userEvent.click(reviewButton);
  291. expect(mockOnMarkReviewed).toHaveBeenCalledWith(['1', '2', '3']);
  292. });
  293. it('mark reviewed disabled for group that is already reviewed', function () {
  294. SelectedGroupStore.add(['1']);
  295. SelectedGroupStore.toggleSelectAll();
  296. GroupStore.loadInitialData([TestStubs.Group({id: '1', inbox: null})]);
  297. render(<WrappedComponent {...defaultProps} />);
  298. expect(screen.getByRole('button', {name: 'Mark Reviewed'})).toBeDisabled();
  299. });
  300. });
  301. describe('sort', function () {
  302. it('calls onSortChange with new sort value', async function () {
  303. const mockOnSortChange = jest.fn();
  304. render(<WrappedComponent onSortChange={mockOnSortChange} />);
  305. await userEvent.click(screen.getByRole('button', {name: 'Last Seen'}));
  306. await userEvent.click(screen.getByText(/Number of events/));
  307. expect(mockOnSortChange).toHaveBeenCalledWith('freq');
  308. });
  309. });
  310. describe('performance issues', function () {
  311. it('disables options that are not supported for performance issues', async () => {
  312. jest
  313. .spyOn(SelectedGroupStore, 'getSelectedIds')
  314. .mockImplementation(() => new Set(['1', '2']));
  315. jest.spyOn(GroupStore, 'get').mockImplementation(id => {
  316. switch (id) {
  317. case '1':
  318. return TestStubs.Group({
  319. issueCategory: IssueCategory.ERROR,
  320. });
  321. default:
  322. return TestStubs.Group({
  323. issueCategory: IssueCategory.PERFORMANCE,
  324. });
  325. }
  326. });
  327. render(<WrappedComponent />);
  328. // Resolve and ignore are supported
  329. expect(screen.getByRole('button', {name: 'Resolve'})).toBeEnabled();
  330. expect(screen.getByRole('button', {name: 'Ignore'})).toBeEnabled();
  331. // Merge is not supported and should be disabled
  332. expect(screen.getByRole('button', {name: 'Merge Selected Issues'})).toBeDisabled();
  333. // Open overflow menu
  334. await userEvent.click(screen.getByRole('button', {name: 'More issue actions'}));
  335. // 'Add to Bookmarks' is supported
  336. expect(
  337. screen.getByRole('menuitemradio', {name: 'Add to Bookmarks'})
  338. ).not.toHaveAttribute('aria-disabled');
  339. // Deleting is not supported and menu item should be disabled
  340. expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toHaveAttribute(
  341. 'aria-disabled',
  342. 'true'
  343. );
  344. });
  345. describe('bulk action performance issues', function () {
  346. const orgWithPerformanceIssues = TestStubs.Organization({
  347. features: ['performance-issues'],
  348. });
  349. it('silently filters out performance issues when bulk deleting', async function () {
  350. const bulkDeleteMock = MockApiClient.addMockResponse({
  351. url: '/organizations/org-slug/issues/',
  352. method: 'DELETE',
  353. });
  354. render(
  355. <Fragment>
  356. <GlobalModal />
  357. <IssueListActions {...defaultProps} query="is:unresolved" queryCount={100} />
  358. </Fragment>,
  359. {organization: orgWithPerformanceIssues}
  360. );
  361. await userEvent.click(screen.getByRole('checkbox'));
  362. await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
  363. await userEvent.click(screen.getByRole('button', {name: 'More issue actions'}));
  364. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Delete'}));
  365. const modal = screen.getByRole('dialog');
  366. expect(
  367. within(modal).getByText(/deleting performance issues is not yet supported/i)
  368. ).toBeInTheDocument();
  369. await userEvent.click(
  370. within(modal).getByRole('button', {name: 'Bulk delete issues'})
  371. );
  372. expect(bulkDeleteMock).toHaveBeenCalledWith(
  373. expect.anything(),
  374. expect.objectContaining({
  375. query: expect.objectContaining({
  376. query: 'is:unresolved issue.category:error',
  377. }),
  378. })
  379. );
  380. });
  381. it('silently filters out performance issues when bulk merging', async function () {
  382. const bulkMergeMock = MockApiClient.addMockResponse({
  383. url: '/organizations/org-slug/issues/',
  384. method: 'PUT',
  385. });
  386. // Ensure that all issues have the same project so we can merge
  387. jest
  388. .spyOn(GroupStore, 'get')
  389. .mockReturnValue(
  390. TestStubs.Group({project: TestStubs.Project({slug: 'project-1'})})
  391. );
  392. render(
  393. <Fragment>
  394. <GlobalModal />
  395. <IssueListActions {...defaultProps} query="is:unresolved" queryCount={100} />
  396. </Fragment>,
  397. {organization: orgWithPerformanceIssues}
  398. );
  399. await userEvent.click(screen.getByRole('checkbox'));
  400. await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
  401. await userEvent.click(
  402. screen.getByRole('button', {name: 'Merge Selected Issues'})
  403. );
  404. const modal = screen.getByRole('dialog');
  405. expect(
  406. within(modal).getByText(/merging performance issues is not yet supported/i)
  407. ).toBeInTheDocument();
  408. await userEvent.click(
  409. within(modal).getByRole('button', {name: 'Bulk merge issues'})
  410. );
  411. expect(bulkMergeMock).toHaveBeenCalledWith(
  412. expect.anything(),
  413. expect.objectContaining({
  414. query: expect.objectContaining({
  415. query: 'is:unresolved issue.category:error',
  416. }),
  417. })
  418. );
  419. });
  420. });
  421. });
  422. });