index.spec.tsx 16 KB

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