index.spec.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import type {Location} from 'history';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {act, render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
  4. import OrganizationStore from 'sentry/stores/organizationStore';
  5. import ProjectsStore from 'sentry/stores/projectsStore';
  6. import TeamStore from 'sentry/stores/teamStore';
  7. import {Organization} from 'sentry/types';
  8. import {trackAnalytics} from 'sentry/utils/analytics';
  9. import AlertRulesList from 'sentry/views/alerts/list/rules';
  10. import {IncidentStatus} from 'sentry/views/alerts/types';
  11. import {OrganizationContext} from 'sentry/views/organizationContext';
  12. jest.mock('sentry/utils/analytics');
  13. describe('AlertRulesList', () => {
  14. const {routerContext, organization, router} = initializeOrg({
  15. organization: {
  16. access: ['alerts:write'],
  17. },
  18. });
  19. TeamStore.loadInitialData([TestStubs.Team()], false, null);
  20. let rulesMock;
  21. let projectMock;
  22. const pageLinks =
  23. '<https://sentry.io/api/0/organizations/org-slug/combined-rules/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", ' +
  24. '<https://sentry.io/api/0/organizations/org-slug/combined-rules/?cursor=0:100:0>; rel="next"; results="true"; cursor="0:100:0"';
  25. const getComponent = (
  26. props: {location?: Location; organization?: Organization} = {}
  27. ) => (
  28. <OrganizationContext.Provider value={props.organization ?? organization}>
  29. <AlertRulesList
  30. {...TestStubs.routeComponentProps()}
  31. organization={props.organization ?? organization}
  32. params={{}}
  33. location={TestStubs.location({query: {}, search: ''})}
  34. router={router}
  35. {...props}
  36. />
  37. </OrganizationContext.Provider>
  38. );
  39. const createWrapper = (props = {}) =>
  40. render(getComponent(props), {context: routerContext});
  41. beforeEach(() => {
  42. rulesMock = MockApiClient.addMockResponse({
  43. url: '/organizations/org-slug/combined-rules/',
  44. headers: {Link: pageLinks},
  45. body: [
  46. TestStubs.ProjectAlertRule({
  47. id: '123',
  48. name: 'First Issue Alert',
  49. projects: ['earth'],
  50. createdBy: {name: 'Samwise', id: 1, email: ''},
  51. }),
  52. TestStubs.MetricRule({
  53. id: '345',
  54. projects: ['earth'],
  55. latestIncident: TestStubs.Incident({
  56. status: IncidentStatus.CRITICAL,
  57. }),
  58. }),
  59. TestStubs.MetricRule({
  60. id: '678',
  61. projects: ['earth'],
  62. latestIncident: null,
  63. }),
  64. ],
  65. });
  66. projectMock = MockApiClient.addMockResponse({
  67. url: '/organizations/org-slug/projects/',
  68. body: [
  69. TestStubs.Project({
  70. slug: 'earth',
  71. platform: 'javascript',
  72. teams: [TestStubs.Team()],
  73. }),
  74. ],
  75. });
  76. act(() => OrganizationStore.onUpdate(organization, {replace: true}));
  77. act(() => ProjectsStore.loadInitialData([]));
  78. });
  79. afterEach(() => {
  80. act(() => ProjectsStore.reset());
  81. MockApiClient.clearMockResponses();
  82. jest.clearAllMocks();
  83. });
  84. it('displays list', async () => {
  85. createWrapper();
  86. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  87. expect(projectMock).toHaveBeenLastCalledWith(
  88. expect.anything(),
  89. expect.objectContaining({
  90. query: expect.objectContaining({query: 'slug:earth'}),
  91. })
  92. );
  93. expect(screen.getAllByTestId('badge-display-name')[0]).toHaveTextContent('earth');
  94. expect(trackAnalytics).toHaveBeenCalledWith(
  95. 'alert_rules.viewed',
  96. expect.objectContaining({
  97. sort: 'incident_status,date_triggered',
  98. })
  99. );
  100. });
  101. it('displays empty state', async () => {
  102. MockApiClient.addMockResponse({
  103. url: '/organizations/org-slug/combined-rules/',
  104. body: [],
  105. });
  106. createWrapper();
  107. expect(
  108. await screen.findByText('No alert rules found for the current query.')
  109. ).toBeInTheDocument();
  110. expect(rulesMock).toHaveBeenCalledTimes(0);
  111. });
  112. it('displays team dropdown context if unassigned', async () => {
  113. createWrapper();
  114. const assignee = (await screen.findAllByTestId('alert-row-assignee'))[0];
  115. const btn = within(assignee).getAllByRole('button')[0];
  116. expect(assignee).toBeInTheDocument();
  117. expect(btn).toBeInTheDocument();
  118. await userEvent.click(btn, {skipHover: true});
  119. expect(screen.getByText('#team-slug')).toBeInTheDocument();
  120. expect(within(assignee).getByText('Unassigned')).toBeInTheDocument();
  121. });
  122. it('assigns rule to team from unassigned', async () => {
  123. const assignMock = MockApiClient.addMockResponse({
  124. method: 'PUT',
  125. url: '/projects/org-slug/earth/rules/123/',
  126. body: [],
  127. });
  128. createWrapper();
  129. const assignee = (await screen.findAllByTestId('alert-row-assignee'))[0];
  130. const btn = within(assignee).getAllByRole('button')[0];
  131. expect(assignee).toBeInTheDocument();
  132. expect(btn).toBeInTheDocument();
  133. await userEvent.click(btn, {skipHover: true});
  134. await userEvent.click(screen.getByText('#team-slug'));
  135. expect(assignMock).toHaveBeenCalledWith(
  136. '/projects/org-slug/earth/rules/123/',
  137. expect.objectContaining({
  138. data: expect.objectContaining({owner: 'team:1'}),
  139. })
  140. );
  141. });
  142. it('displays dropdown context menu with actions', async () => {
  143. createWrapper();
  144. const actions = (await screen.findAllByRole('button', {name: 'Actions'}))[0];
  145. expect(actions).toBeInTheDocument();
  146. await userEvent.click(actions);
  147. expect(screen.getByText('Edit')).toBeInTheDocument();
  148. expect(screen.getByText('Delete')).toBeInTheDocument();
  149. expect(screen.getByText('Duplicate')).toBeInTheDocument();
  150. });
  151. it('sends user to new alert page on duplicate action', async () => {
  152. createWrapper();
  153. const actions = (await screen.findAllByRole('button', {name: 'Actions'}))[0];
  154. expect(actions).toBeInTheDocument();
  155. await userEvent.click(actions);
  156. const duplicate = await screen.findByText('Duplicate');
  157. expect(duplicate).toBeInTheDocument();
  158. await userEvent.click(duplicate);
  159. expect(router.push).toHaveBeenCalledWith({
  160. pathname: '/organizations/org-slug/alerts/new/issue/',
  161. query: {
  162. createFromDuplicate: true,
  163. duplicateRuleId: '123',
  164. project: 'earth',
  165. referrer: 'alert_stream',
  166. },
  167. });
  168. });
  169. it('sorts by name', async () => {
  170. const {rerender} = createWrapper();
  171. // The name column is not used for sorting
  172. expect(await screen.findByText('Alert Rule')).toHaveAttribute('aria-sort', 'none');
  173. // Sort by the name column
  174. rerender(
  175. getComponent({
  176. location: TestStubs.location({
  177. query: {asc: '1', sort: 'name'},
  178. search: '?asc=1&sort=name`',
  179. }),
  180. })
  181. );
  182. expect(await screen.findByText('Alert Rule')).toHaveAttribute(
  183. 'aria-sort',
  184. 'ascending'
  185. );
  186. expect(rulesMock).toHaveBeenCalledTimes(2);
  187. expect(rulesMock).toHaveBeenCalledWith(
  188. '/organizations/org-slug/combined-rules/',
  189. expect.objectContaining({
  190. query: expect.objectContaining({sort: 'name', asc: '1'}),
  191. })
  192. );
  193. });
  194. it('disables the new alert button for members', async () => {
  195. const noAccessOrg = {
  196. ...organization,
  197. access: [],
  198. };
  199. render(getComponent({organization: noAccessOrg}), {
  200. context: TestStubs.routerContext([{organization: noAccessOrg}]),
  201. organization: noAccessOrg,
  202. });
  203. expect(await screen.findByLabelText('Create Alert')).toBeDisabled();
  204. });
  205. it('searches by name', async () => {
  206. createWrapper();
  207. const search = await screen.findByPlaceholderText('Search by name');
  208. expect(search).toBeInTheDocument();
  209. const testQuery = 'test name';
  210. await userEvent.type(search, `${testQuery}{enter}`);
  211. expect(router.push).toHaveBeenCalledWith(
  212. expect.objectContaining({
  213. query: {
  214. name: testQuery,
  215. expand: ['latestIncident', 'lastTriggered'],
  216. sort: ['incident_status', 'date_triggered'],
  217. team: ['myteams', 'unassigned'],
  218. },
  219. })
  220. );
  221. });
  222. it('uses empty team query parameter when removing all teams', async () => {
  223. const {rerender} = createWrapper();
  224. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  225. rerender(
  226. getComponent({
  227. location: TestStubs.location({
  228. query: {team: 'myteams'},
  229. search: '?team=myteams`',
  230. }),
  231. })
  232. );
  233. await userEvent.click(await screen.findByRole('button', {name: 'My Teams'}));
  234. // Uncheck myteams
  235. const myTeams = await screen.findAllByText('My Teams');
  236. await userEvent.click(myTeams[1]);
  237. expect(router.push).toHaveBeenCalledWith(
  238. expect.objectContaining({
  239. query: {
  240. expand: ['latestIncident', 'lastTriggered'],
  241. sort: ['incident_status', 'date_triggered'],
  242. team: '',
  243. },
  244. })
  245. );
  246. });
  247. it('displays alert status', async () => {
  248. createWrapper();
  249. const rules = await screen.findAllByText('My Incident Rule');
  250. expect(rules[0]).toBeInTheDocument();
  251. expect(screen.getByText('Triggered')).toBeInTheDocument();
  252. expect(screen.getByText('Above 70')).toBeInTheDocument();
  253. expect(screen.getByText('Below 36')).toBeInTheDocument();
  254. expect(screen.getAllByTestId('alert-badge')[0]).toBeInTheDocument();
  255. });
  256. it('sorts by alert rule', async () => {
  257. createWrapper({organization});
  258. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  259. expect(rulesMock).toHaveBeenCalledWith(
  260. '/organizations/org-slug/combined-rules/',
  261. expect.objectContaining({
  262. query: {
  263. expand: ['latestIncident', 'lastTriggered'],
  264. sort: ['incident_status', 'date_triggered'],
  265. team: ['myteams', 'unassigned'],
  266. },
  267. })
  268. );
  269. });
  270. it('preserves empty team query parameter on pagination', async () => {
  271. createWrapper({
  272. organization,
  273. location: {query: {team: ''}, search: '?team=`'},
  274. });
  275. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  276. await userEvent.click(screen.getByLabelText('Next'));
  277. expect(router.push).toHaveBeenCalledWith(
  278. expect.objectContaining({
  279. query: {
  280. expand: ['latestIncident', 'lastTriggered'],
  281. sort: ['incident_status', 'date_triggered'],
  282. team: '',
  283. cursor: '0:100:0',
  284. },
  285. })
  286. );
  287. });
  288. });