index.spec.tsx 18 KB

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