projectGeneralSettings.spec.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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 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 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 tick();
  237. await act(tick);
  238. wrapper.update();
  239. // updates ProjectsStore
  240. expect(ProjectsStore.itemsById['2'].platform).toBe('javascript');
  241. });
  242. it('changing name updates ProjectsStore', async function () {
  243. const params = {orgId: org.slug, projectId: project.slug};
  244. act(() => ProjectsStore.loadInitialData([project]));
  245. putMock = MockApiClient.addMockResponse({
  246. url: `/projects/${org.slug}/${project.slug}/`,
  247. method: 'PUT',
  248. body: {
  249. ...project,
  250. slug: 'new-project',
  251. },
  252. });
  253. wrapper = mountWithTheme(
  254. <ProjectContext orgId={org.slug} projectId={project.slug}>
  255. <ProjectGeneralSettings
  256. routes={[]}
  257. location={routerContext.context.location}
  258. params={params}
  259. />
  260. </ProjectContext>,
  261. routerContext
  262. );
  263. await tick();
  264. wrapper.update();
  265. // Change slug to new-slug
  266. wrapper
  267. .find('input[name="name"]')
  268. .simulate('change', {target: {value: 'New Project'}})
  269. .simulate('blur');
  270. // Slug does not save on blur
  271. expect(putMock).not.toHaveBeenCalled();
  272. wrapper.find('Alert button[aria-label="Save"]').simulate('click');
  273. // fetches new slug
  274. const newProjectGet = MockApiClient.addMockResponse({
  275. url: `/projects/${org.slug}/new-project/`,
  276. method: 'GET',
  277. body: {...project, slug: 'new-project'},
  278. });
  279. const newProjectMembers = MockApiClient.addMockResponse({
  280. url: `/organizations/${org.slug}/users/`,
  281. method: 'GET',
  282. body: [],
  283. });
  284. await act(tick);
  285. wrapper.update();
  286. // updates ProjectsStore
  287. expect(ProjectsStore.itemsById['2'].slug).toBe('new-project');
  288. expect(browserHistory.replace).toHaveBeenCalled();
  289. expect(wrapper.find('Input[name="name"]').prop('value')).toBe('new-project');
  290. wrapper.setProps({
  291. projectId: 'new-project',
  292. });
  293. await tick();
  294. wrapper.update();
  295. expect(newProjectGet).toHaveBeenCalled();
  296. expect(newProjectMembers).toHaveBeenCalled();
  297. });
  298. describe('Non-"save on blur" Field', function () {
  299. beforeEach(function () {
  300. const params = {orgId: org.slug, projectId: project.slug};
  301. act(() => ProjectsStore.loadInitialData([project]));
  302. putMock = MockApiClient.addMockResponse({
  303. url: `/projects/${org.slug}/${project.slug}/`,
  304. method: 'PUT',
  305. body: {
  306. ...project,
  307. slug: 'new-project',
  308. },
  309. });
  310. wrapper = mountWithTheme(
  311. <ProjectContext orgId={org.slug} projectId={project.slug}>
  312. <ProjectGeneralSettings
  313. routes={[]}
  314. location={routerContext.context.location}
  315. params={params}
  316. />
  317. </ProjectContext>,
  318. routerContext
  319. );
  320. });
  321. afterEach(() => {
  322. wrapper?.unmount();
  323. modal?.unmount();
  324. });
  325. it('can cancel unsaved changes for a field', async function () {
  326. await tick();
  327. wrapper.update();
  328. // Initially does not have "Cancel" button
  329. expect(wrapper.find('Alert button[aria-label="Cancel"]')).toHaveLength(0);
  330. // Has initial value
  331. expect(wrapper.find('input[name="resolveAge"]').prop('value')).toBe(19);
  332. // Change value
  333. wrapper
  334. .find('input[name="resolveAge"]')
  335. .simulate('input', {target: {value: 12}})
  336. .simulate('mouseUp');
  337. // Has updated value
  338. expect(wrapper.find('input[name="resolveAge"]').prop('value')).toBe(12);
  339. // Has "Cancel" button visible
  340. expect(wrapper.find('Alert button[aria-label="Cancel"]')).toHaveLength(1);
  341. // Click cancel
  342. wrapper.find('Alert button[aria-label="Cancel"]').simulate('click');
  343. wrapper.update();
  344. // Cancel row should disappear
  345. expect(wrapper.find('Alert button[aria-label="Cancel"]')).toHaveLength(0);
  346. // Value should be reverted
  347. expect(wrapper.find('input[name="resolveAge"]').prop('value')).toBe(19);
  348. // PUT should not be called
  349. expect(putMock).not.toHaveBeenCalled();
  350. });
  351. it('saves when value is changed and "Save" clicked', async function () {
  352. // This test has been flaky and using act() isn't removing the flakyness.
  353. await tick();
  354. wrapper.update();
  355. // Initially does not have "Save" button
  356. expect(wrapper.find('Alert button[aria-label="Save"]')).toHaveLength(0);
  357. // Change value
  358. wrapper
  359. .find('input[name="resolveAge"]')
  360. .simulate('input', {target: {value: 12}})
  361. .simulate('mouseUp');
  362. await tick();
  363. wrapper.update();
  364. // Has "Save" button visible
  365. expect(wrapper.find('Alert button[aria-label="Save"]')).toHaveLength(1);
  366. // Should not have put mock called yet
  367. expect(putMock).not.toHaveBeenCalled();
  368. // Click "Save"
  369. wrapper.find('Alert button[aria-label="Save"]').simulate('click');
  370. await tick();
  371. wrapper.update();
  372. // API endpoint should have been called
  373. expect(putMock).toHaveBeenCalledWith(
  374. expect.anything(),
  375. expect.objectContaining({
  376. data: {
  377. resolveAge: 12,
  378. },
  379. })
  380. );
  381. // Should hide "Save" button after saving
  382. await act(tick);
  383. wrapper.update();
  384. expect(wrapper.find('Alert button[aria-label="Save"]')).toHaveLength(0);
  385. });
  386. });
  387. });