index.spec.tsx 16 KB

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