index.spec.tsx 16 KB

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