index.spec.jsx 16 KB

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