index.spec.tsx 16 KB

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