projectGeneralSettings.spec.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import {browserHistory} from 'react-router';
  2. import {mountWithTheme} from 'sentry-test/enzyme';
  3. import {mountGlobalModal} from 'sentry-test/modal';
  4. import {act} from 'sentry-test/reactTestingLibrary';
  5. import {selectByValue} from 'sentry-test/select-new';
  6. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  7. import {removePageFiltersStorage} from 'sentry/components/organizations/pageFilters/persistence';
  8. import ProjectsStore from 'sentry/stores/projectsStore';
  9. import ProjectContext from 'sentry/views/projects/projectContext';
  10. import ProjectGeneralSettings from 'sentry/views/settings/projectGeneralSettings';
  11. jest.mock('sentry/actionCreators/indicator');
  12. jest.mock('sentry/components/organizations/pageFilters/persistence');
  13. describe('projectGeneralSettings', function () {
  14. const org = TestStubs.Organization();
  15. const project = TestStubs.ProjectDetails();
  16. const groupingConfigs = TestStubs.GroupingConfigs();
  17. const groupingEnhancements = TestStubs.GroupingEnhancements();
  18. let routerContext;
  19. let putMock;
  20. let wrapper;
  21. let modal;
  22. beforeEach(function () {
  23. jest.spyOn(window.location, 'assign');
  24. routerContext = TestStubs.routerContext([
  25. {
  26. router: TestStubs.router({
  27. params: {
  28. projectId: project.slug,
  29. orgId: org.slug,
  30. },
  31. }),
  32. },
  33. ]);
  34. MockApiClient.clearMockResponses();
  35. MockApiClient.addMockResponse({
  36. url: '/grouping-configs/',
  37. method: 'GET',
  38. body: groupingConfigs,
  39. });
  40. MockApiClient.addMockResponse({
  41. url: '/grouping-enhancements/',
  42. method: 'GET',
  43. body: groupingEnhancements,
  44. });
  45. MockApiClient.addMockResponse({
  46. url: `/projects/${org.slug}/${project.slug}/`,
  47. method: 'GET',
  48. body: project,
  49. });
  50. MockApiClient.addMockResponse({
  51. url: `/projects/${org.slug}/${project.slug}/environments/`,
  52. method: 'GET',
  53. body: [],
  54. });
  55. MockApiClient.addMockResponse({
  56. url: `/organizations/${org.slug}/users/`,
  57. method: 'GET',
  58. body: [],
  59. });
  60. });
  61. afterEach(function () {
  62. window.location.assign.mockRestore();
  63. MockApiClient.clearMockResponses();
  64. addSuccessMessage.mockReset();
  65. addErrorMessage.mockReset();
  66. if (wrapper?.length) {
  67. wrapper.unmount();
  68. wrapper = undefined;
  69. }
  70. if (modal?.length) {
  71. modal.unmount();
  72. modal = undefined;
  73. }
  74. });
  75. it('renders form fields', function () {
  76. wrapper = mountWithTheme(
  77. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />
  78. );
  79. expect(wrapper.find('Input[name="name"]').prop('value')).toBe('Project Name');
  80. expect(wrapper.find('Input[name="subjectPrefix"]').prop('value')).toBe('[my-org]');
  81. expect(wrapper.find('RangeSlider[name="resolveAge"]').prop('value')).toBe(48);
  82. expect(wrapper.find('TextArea[name="allowedDomains"]').prop('value')).toBe(
  83. 'example.com\nhttps://example.com'
  84. );
  85. expect(wrapper.find('Switch[name="scrapeJavaScript"]').prop('isDisabled')).toBe(
  86. false
  87. );
  88. expect(wrapper.find('Switch[name="scrapeJavaScript"]').prop('isActive')).toBeTruthy();
  89. expect(wrapper.find('Input[name="securityToken"]').prop('value')).toBe(
  90. 'security-token'
  91. );
  92. expect(wrapper.find('Input[name="securityTokenHeader"]').prop('value')).toBe(
  93. 'x-security-header'
  94. );
  95. expect(wrapper.find('Switch[name="verifySSL"]').prop('isActive')).toBeTruthy();
  96. });
  97. it('disables scrapeJavaScript when equivalent org setting is false', function () {
  98. routerContext.context.organization.scrapeJavaScript = false;
  99. wrapper = mountWithTheme(
  100. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />,
  101. routerContext
  102. );
  103. expect(wrapper.find('Switch[name="scrapeJavaScript"]').prop('isDisabled')).toBe(true);
  104. expect(wrapper.find('Switch[name="scrapeJavaScript"]').prop('isActive')).toBeFalsy();
  105. });
  106. it('project admins can remove project', async function () {
  107. const deleteMock = MockApiClient.addMockResponse({
  108. url: `/projects/${org.slug}/${project.slug}/`,
  109. method: 'DELETE',
  110. });
  111. wrapper = mountWithTheme(
  112. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />
  113. );
  114. const removeBtn = wrapper.find('.ref-remove-project').first();
  115. expect(removeBtn.prop('children')).toBe('Remove Project');
  116. // Click button
  117. removeBtn.simulate('click');
  118. // Confirm Modal
  119. modal = await mountGlobalModal();
  120. modal.find('Button[priority="danger"]').simulate('click');
  121. expect(deleteMock).toHaveBeenCalled();
  122. expect(removePageFiltersStorage).toHaveBeenCalledWith('org-slug');
  123. });
  124. it('project admins can transfer project', async function () {
  125. const deleteMock = MockApiClient.addMockResponse({
  126. url: `/projects/${org.slug}/${project.slug}/transfer/`,
  127. method: 'POST',
  128. });
  129. wrapper = mountWithTheme(
  130. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />
  131. );
  132. const removeBtn = wrapper.find('.ref-transfer-project').first();
  133. expect(removeBtn.prop('children')).toBe('Transfer Project');
  134. // Click button
  135. removeBtn.simulate('click');
  136. // Confirm Modal
  137. modal = await mountGlobalModal();
  138. modal
  139. .find('input[name="email"]')
  140. .simulate('change', {target: {value: 'billy@sentry.io'}});
  141. modal.find('Modal Button[priority="danger"]').simulate('click');
  142. await act(tick);
  143. await modal.update();
  144. expect(addSuccessMessage).toHaveBeenCalled();
  145. expect(deleteMock).toHaveBeenCalledWith(
  146. `/projects/${org.slug}/${project.slug}/transfer/`,
  147. expect.objectContaining({
  148. method: 'POST',
  149. data: {
  150. email: 'billy@sentry.io',
  151. },
  152. })
  153. );
  154. });
  155. it('handles errors on transfer project', async function () {
  156. const deleteMock = MockApiClient.addMockResponse({
  157. url: `/projects/${org.slug}/${project.slug}/transfer/`,
  158. method: 'POST',
  159. statusCode: 400,
  160. body: {detail: 'An organization owner could not be found'},
  161. });
  162. wrapper = mountWithTheme(
  163. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />
  164. );
  165. const removeBtn = wrapper.find('.ref-transfer-project').first();
  166. expect(removeBtn.prop('children')).toBe('Transfer Project');
  167. // Click button
  168. removeBtn.simulate('click');
  169. // Confirm Modal
  170. modal = await mountGlobalModal();
  171. modal
  172. .find('input[name="email"]')
  173. .simulate('change', {target: {value: 'billy@sentry.io'}});
  174. modal.find('Modal Button[priority="danger"]').simulate('click');
  175. await act(tick);
  176. await modal.update();
  177. expect(deleteMock).toHaveBeenCalled();
  178. expect(addSuccessMessage).not.toHaveBeenCalled();
  179. expect(addErrorMessage).toHaveBeenCalled();
  180. const content = mountWithTheme(addErrorMessage.mock.calls[0][0]);
  181. expect(content.text()).toEqual(
  182. expect.stringContaining('An organization owner could not be found')
  183. );
  184. });
  185. it('displays transfer/remove message for non-admins', function () {
  186. routerContext.context.organization.access = ['org:read'];
  187. wrapper = mountWithTheme(
  188. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />,
  189. routerContext
  190. );
  191. expect(wrapper.html()).toContain(
  192. 'You do not have the required permission to remove this project.'
  193. );
  194. expect(wrapper.html()).toContain(
  195. 'You do not have the required permission to transfer this project.'
  196. );
  197. });
  198. it('disables the form for users without write permissions', function () {
  199. routerContext.context.organization.access = ['org:read'];
  200. wrapper = mountWithTheme(
  201. <ProjectGeneralSettings params={{orgId: org.slug, projectId: project.slug}} />,
  202. routerContext
  203. );
  204. expect(wrapper.find('FormField[disabled=false]')).toHaveLength(0);
  205. expect(wrapper.find('Alert').first().text()).toBe(
  206. 'These settings can only be edited by users with the organization owner, manager, or admin role.'
  207. );
  208. });
  209. it('changing project platform updates ProjectsStore', async function () {
  210. const params = {orgId: org.slug, projectId: project.slug};
  211. act(() => ProjectsStore.loadInitialData([project]));
  212. putMock = MockApiClient.addMockResponse({
  213. url: `/projects/${org.slug}/${project.slug}/`,
  214. method: 'PUT',
  215. body: {
  216. ...project,
  217. platform: 'javascript',
  218. },
  219. });
  220. wrapper = mountWithTheme(
  221. <ProjectContext orgId={org.slug} projectId={project.slug}>
  222. <ProjectGeneralSettings
  223. routes={[]}
  224. location={routerContext.context.location}
  225. params={params}
  226. />
  227. </ProjectContext>,
  228. routerContext
  229. );
  230. await act(tick);
  231. wrapper.update();
  232. // Change slug to new-slug
  233. selectByValue(wrapper, 'javascript');
  234. // Slug does not save on blur
  235. expect(putMock).toHaveBeenCalled();
  236. await act(tick);
  237. wrapper.update();
  238. // updates ProjectsStore
  239. expect(ProjectsStore.itemsById['2'].platform).toBe('javascript');
  240. });
  241. it('changing name updates ProjectsStore', async function () {
  242. const params = {orgId: org.slug, projectId: project.slug};
  243. act(() => ProjectsStore.loadInitialData([project]));
  244. putMock = MockApiClient.addMockResponse({
  245. url: `/projects/${org.slug}/${project.slug}/`,
  246. method: 'PUT',
  247. body: {
  248. ...project,
  249. slug: 'new-project',
  250. },
  251. });
  252. wrapper = mountWithTheme(
  253. <ProjectContext orgId={org.slug} projectId={project.slug}>
  254. <ProjectGeneralSettings
  255. routes={[]}
  256. location={routerContext.context.location}
  257. params={params}
  258. />
  259. </ProjectContext>,
  260. routerContext
  261. );
  262. await act(tick);
  263. wrapper.update();
  264. // Change slug to new-slug
  265. wrapper
  266. .find('input[name="name"]')
  267. .simulate('change', {target: {value: 'New Project'}})
  268. .simulate('blur');
  269. // Slug does not save on blur
  270. expect(putMock).not.toHaveBeenCalled();
  271. wrapper.find('Alert button[aria-label="Save"]').simulate('click');
  272. // fetches new slug
  273. const newProjectGet = MockApiClient.addMockResponse({
  274. url: `/projects/${org.slug}/new-project/`,
  275. method: 'GET',
  276. body: {...project, slug: 'new-project'},
  277. });
  278. const newProjectMembers = MockApiClient.addMockResponse({
  279. url: `/organizations/${org.slug}/users/`,
  280. method: 'GET',
  281. body: [],
  282. });
  283. await act(tick);
  284. wrapper.update();
  285. // updates ProjectsStore
  286. expect(ProjectsStore.itemsById['2'].slug).toBe('new-project');
  287. expect(browserHistory.replace).toHaveBeenCalled();
  288. expect(wrapper.find('Input[name="name"]').prop('value')).toBe('new-project');
  289. wrapper.setProps({
  290. projectId: 'new-project',
  291. });
  292. await act(tick);
  293. wrapper.update();
  294. expect(newProjectGet).toHaveBeenCalled();
  295. expect(newProjectMembers).toHaveBeenCalled();
  296. });
  297. describe('Non-"save on blur" Field', function () {
  298. beforeEach(function () {
  299. const params = {orgId: org.slug, projectId: project.slug};
  300. act(() => ProjectsStore.loadInitialData([project]));
  301. putMock = MockApiClient.addMockResponse({
  302. url: `/projects/${org.slug}/${project.slug}/`,
  303. method: 'PUT',
  304. body: {
  305. ...project,
  306. slug: 'new-project',
  307. },
  308. });
  309. wrapper = mountWithTheme(
  310. <ProjectContext orgId={org.slug} projectId={project.slug}>
  311. <ProjectGeneralSettings
  312. routes={[]}
  313. location={routerContext.context.location}
  314. params={params}
  315. />
  316. </ProjectContext>,
  317. routerContext
  318. );
  319. });
  320. afterEach(() => {
  321. wrapper?.unmount();
  322. modal?.unmount();
  323. });
  324. it('can cancel unsaved changes for a field', async function () {
  325. await act(tick);
  326. wrapper.update();
  327. // Initially does not have "Cancel" button
  328. expect(wrapper.find('Alert button[aria-label="Cancel"]')).toHaveLength(0);
  329. // Has initial value
  330. expect(wrapper.find('input[name="resolveAge"]').prop('value')).toBe(19);
  331. // Change value
  332. wrapper
  333. .find('input[name="resolveAge"]')
  334. .simulate('input', {target: {value: 12}})
  335. .simulate('mouseUp');
  336. // Has updated value
  337. expect(wrapper.find('input[name="resolveAge"]').prop('value')).toBe(12);
  338. // Has "Cancel" button visible
  339. expect(wrapper.find('Alert button[aria-label="Cancel"]')).toHaveLength(1);
  340. // Click cancel
  341. wrapper.find('Alert button[aria-label="Cancel"]').simulate('click');
  342. wrapper.update();
  343. // Cancel row should disappear
  344. expect(wrapper.find('Alert button[aria-label="Cancel"]')).toHaveLength(0);
  345. // Value should be reverted
  346. expect(wrapper.find('input[name="resolveAge"]').prop('value')).toBe(19);
  347. // PUT should not be called
  348. expect(putMock).not.toHaveBeenCalled();
  349. });
  350. it('saves when value is changed and "Save" clicked', async function () {
  351. // This test has been flaky and using act() isn't removing the flakyness.
  352. await act(tick);
  353. wrapper.update();
  354. // Initially does not have "Save" button
  355. expect(wrapper.find('Alert button[aria-label="Save"]')).toHaveLength(0);
  356. // Change value
  357. wrapper
  358. .find('input[name="resolveAge"]')
  359. .simulate('input', {target: {value: 12}})
  360. .simulate('mouseUp');
  361. await act(tick);
  362. wrapper.update();
  363. // Has "Save" button visible
  364. expect(wrapper.find('Alert button[aria-label="Save"]')).toHaveLength(1);
  365. // Should not have put mock called yet
  366. expect(putMock).not.toHaveBeenCalled();
  367. // Click "Save"
  368. wrapper.find('Alert button[aria-label="Save"]').simulate('click');
  369. await act(tick);
  370. wrapper.update();
  371. // API endpoint should have been called
  372. expect(putMock).toHaveBeenCalledWith(
  373. expect.anything(),
  374. expect.objectContaining({
  375. data: {
  376. resolveAge: 12,
  377. },
  378. })
  379. );
  380. // Should hide "Save" button after saving
  381. await act(tick);
  382. wrapper.update();
  383. expect(wrapper.find('Alert button[aria-label="Save"]')).toHaveLength(0);
  384. });
  385. });
  386. });