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