index.spec.tsx 16 KB

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