From 71615cf32cf32fa6a6756d5ddfd8201a52c5fb86 Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Tue, 8 Jan 2019 15:49:59 +0100 Subject: [PATCH] SONAR-9392 Allow profile to be extended directly --- .../components/ExtendProfileForm.tsx | 139 ++++++++++++++++++ .../components/ProfileActions.tsx | 99 +++++++++---- .../__tests__/ExtendProfileForm-test.tsx | 69 +++++++++ .../__tests__/ProfileActions-test.tsx | 108 ++++++++------ .../ExtendProfileForm-test.tsx.snap | 69 +++++++++ .../ProfileActions-test.tsx.snap | 6 + .../home/CreateProfileForm.tsx | 31 ++-- .../resources/org/sonar/l10n/core.properties | 4 +- 8 files changed, 424 insertions(+), 101 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx create mode 100644 server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx new file mode 100644 index 00000000000..6b8fd8db20d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx @@ -0,0 +1,139 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { Profile } from '../types'; +import { createQualityProfile, changeProfileParent } from '../../../api/quality-profiles'; +import Modal from '../../../components/controls/Modal'; +import { SubmitButton, ResetButtonLink } from '../../../components/ui/buttons'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; + +interface Props { + onClose: () => void; + onExtend: (name: string) => void; + organization: string | null; + profile: Profile; +} + +interface State { + loading: boolean; + name?: string; +} + +type ValidState = State & Required>; + +export default class ExtendProfileForm extends React.PureComponent { + mounted = false; + state: State = { loading: false }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + canSubmit = (state: State): state is ValidState => { + return Boolean(state.name && state.name.length); + }; + + handleNameChange = (event: React.SyntheticEvent) => { + this.setState({ name: event.currentTarget.value }); + }; + + handleFormSubmit = async () => { + if (this.canSubmit(this.state)) { + const { organization, profile: parentProfile } = this.props; + const { name } = this.state; + + const data = new FormData(); + + data.append('language', parentProfile.language); + data.append('name', name); + + if (organization) { + data.append('organization', organization); + } + + this.setState({ loading: true }); + + try { + const { profile: newProfile } = await createQualityProfile(data); + await changeProfileParent(newProfile.key, parentProfile.key); + this.props.onExtend(newProfile.name); + } finally { + if (this.mounted) { + this.setState({ loading: false }); + } + } + } + }; + + render() { + const { profile } = this.props; + const header = translateWithParameters( + 'quality_profiles.extend_x_title', + profile.name, + profile.languageName + ); + + return ( + +
+
+

{header}

+
+
+
+ + +
+
+
+ + + {translate('copy')} + + + {translate('cancel')} + +
+
+
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx index 25b13a13cf9..e5d833fca08 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import RenameProfileForm from './RenameProfileForm'; import CopyProfileForm from './CopyProfileForm'; import DeleteProfileForm from './DeleteProfileForm'; +import ExtendProfileForm from './ExtendProfileForm'; import { translate } from '../../../helpers/l10n'; import { getRulesUrl } from '../../../helpers/urls'; import { setDefaultProfile } from '../../../api/quality-profiles'; @@ -43,6 +44,7 @@ interface Props { interface State { copyFormOpen: boolean; + extendFormOpen: boolean; deleteFormOpen: boolean; renameFormOpen: boolean; } @@ -50,14 +52,58 @@ interface State { export class ProfileActions extends React.PureComponent { state: State = { copyFormOpen: false, + extendFormOpen: false, deleteFormOpen: false, renameFormOpen: false }; + closeCopyForm = () => { + this.setState({ copyFormOpen: false }); + }; + + closeDeleteForm = () => { + this.setState({ deleteFormOpen: false }); + }; + + closeExtendForm = () => { + this.setState({ extendFormOpen: false }); + }; + + closeRenameForm = () => { + this.setState({ renameFormOpen: false }); + }; + + handleCopyClick = () => { + this.setState({ copyFormOpen: true }); + }; + + handleDeleteClick = () => { + this.setState({ deleteFormOpen: true }); + }; + + handleExtendClick = () => { + this.setState({ extendFormOpen: true }); + }; + handleRenameClick = () => { this.setState({ renameFormOpen: true }); }; + handleProfileCopy = (name: string) => { + this.closeCopyForm(); + this.navigateToNewProfile(name); + }; + + handleProfileDelete = () => { + this.props.router.replace(getProfilesPath(this.props.organization)); + this.props.updateProfiles(); + }; + + handleProfileExtend = (name: string) => { + this.closeExtendForm(); + this.navigateToNewProfile(name); + }; + handleProfileRename = (name: string) => { this.closeRenameForm(); this.props.updateProfiles().then( @@ -72,16 +118,11 @@ export class ProfileActions extends React.PureComponent { ); }; - closeRenameForm = () => { - this.setState({ renameFormOpen: false }); - }; - - handleCopyClick = () => { - this.setState({ copyFormOpen: true }); + handleSetDefaultClick = () => { + setDefaultProfile(this.props.profile.key).then(this.props.updateProfiles, () => {}); }; - handleProfileCopy = (name: string) => { - this.closeCopyForm(); + navigateToNewProfile = (name: string) => { this.props.updateProfiles().then( () => { this.props.router.push( @@ -92,27 +133,6 @@ export class ProfileActions extends React.PureComponent { ); }; - closeCopyForm = () => { - this.setState({ copyFormOpen: false }); - }; - - handleSetDefaultClick = () => { - setDefaultProfile(this.props.profile.key).then(this.props.updateProfiles, () => {}); - }; - - handleDeleteClick = () => { - this.setState({ deleteFormOpen: true }); - }; - - handleProfileDelete = () => { - this.props.router.replace(getProfilesPath(this.props.organization)); - this.props.updateProfiles(); - }; - - closeDeleteForm = () => { - this.setState({ deleteFormOpen: false }); - }; - render() { const { profile } = this.props; const { actions = {} } = profile; @@ -156,9 +176,15 @@ export class ProfileActions extends React.PureComponent { {actions.copy && ( - - {translate('copy')} - + <> + + {translate('copy')} + + + + {translate('extend')} + + )} {actions.edit && ( @@ -195,6 +221,15 @@ export class ProfileActions extends React.PureComponent { /> )} + {this.state.extendFormOpen && ( + + )} + {this.state.deleteFormOpen && ( ({ + createQualityProfile: jest.fn().mockResolvedValue({ profile: { key: 'new-profile' } }), + changeProfileParent: jest.fn().mockResolvedValue(true) +})); + +it('should render correctly', () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); +}); + +it.only('should correctly create a new profile and extend the existing one', async () => { + const profile = mockQualityProfile(); + const organization = 'org'; + const name = 'New name'; + const wrapper = shallowRender({ organization, profile }); + + click(wrapper.find('SubmitButton')); + expect(createQualityProfile).not.toHaveBeenCalled(); + expect(changeProfileParent).not.toHaveBeenCalled(); + + wrapper.setState({ name }).update(); + click(wrapper.find('SubmitButton')); + await Promise.resolve(setImmediate); + + const data = new FormData(); + data.append('language', profile.language); + data.append('name', name); + data.append('organization', organization); + expect(createQualityProfile).toHaveBeenCalledWith(data); + expect(changeProfileParent).toHaveBeenCalledWith('new-profile', profile.key); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx index e8f9318c739..8b4bfdd4c6b 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { ProfileActions } from '../ProfileActions'; -import { click, waitAndUpdate } from '../../../../helpers/testUtils'; +import { click, waitAndUpdate, mockRouter } from '../../../../helpers/testUtils'; import { mockQualityProfile } from '../../testUtils'; const PROFILE = mockQualityProfile({ @@ -33,75 +33,87 @@ const PROFILE = mockQualityProfile({ }); it('renders with no permissions', () => { - expect( - shallow( - - ) - ).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); }); it('renders with permission to edit only', () => { - expect( - shallow( - - ) - ).toMatchSnapshot(); + expect(shallowRender({ profile: { ...PROFILE, actions: { edit: true } } })).toMatchSnapshot(); }); it('renders with all permissions', () => { expect( - shallow( - - ) + shallowRender({ + profile: { + ...PROFILE, + actions: { + copy: true, + edit: true, + delete: true, + setAsDefault: true, + associateProjects: true + } + } + }) ).toMatchSnapshot(); }); it('should copy profile', async () => { + const name = 'new-name'; const updateProfiles = jest.fn(() => Promise.resolve()); const push = jest.fn(); - const wrapper = shallow( - - ); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: { push, replace: jest.fn() }, + updateProfiles + }); click(wrapper.find('[id="quality-profile-copy"]')); expect(wrapper.find('CopyProfileForm').exists()).toBe(true); - wrapper.find('CopyProfileForm').prop('onCopy')('new-name'); + wrapper.find('CopyProfileForm').prop('onCopy')(name); expect(updateProfiles).toBeCalled(); await waitAndUpdate(wrapper); expect(push).toBeCalledWith({ pathname: '/organizations/org/quality_profiles/show', - query: { language: 'js', name: 'new-name' } + query: { language: 'js', name } }); expect(wrapper.find('CopyProfileForm').exists()).toBe(false); }); + +it('should extend profile', async () => { + const name = 'new-name'; + const updateProfiles = jest.fn(() => Promise.resolve()); + const push = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: { push, replace: jest.fn() }, + updateProfiles + }); + + click(wrapper.find('[id="quality-profile-extend"]')); + expect(wrapper.find('ExtendProfileForm').exists()).toBe(true); + + wrapper.find('ExtendProfileForm').prop('onExtend')(name); + expect(updateProfiles).toBeCalled(); + await waitAndUpdate(wrapper); + + expect(push).toBeCalledWith({ + pathname: '/organizations/org/quality_profiles/show', + query: { language: 'js', name } + }); + expect(wrapper.find('ExtendProfileForm').exists()).toBe(false); +}); + +function shallowRender(props: Partial = {}) { + const router = mockRouter(); + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap new file mode 100644 index 00000000000..f0dbf25d18c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + +
+
+

+ quality_profiles.extend_x_title.name.JavaScript +

+
+
+
+ + +
+
+
+ + + copy + + + cancel + +
+
+
+`; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap index 0578972611b..0066c498c61 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap @@ -44,6 +44,12 @@ exports[`renders with all permissions 1`] = ` > copy + + extend + this.setState({ parent: option ? option.value : undefined }); }; - handleFormSubmit = (event: React.SyntheticEvent) => { + handleFormSubmit = async (event: React.SyntheticEvent) => { event.preventDefault(); this.setState({ loading: true }); @@ -97,26 +97,17 @@ export default class CreateProfileForm extends React.PureComponent data.append('organization', this.props.organization); } - createQualityProfile(data).then( - ({ profile }: { profile: Profile }) => { - if (this.state.parent) { - // eslint-disable-next-line promise/no-nesting - changeProfileParent(profile.key, this.state.parent).then( - () => { - this.props.onCreate(profile); - }, - () => {} - ); - } else { - this.props.onCreate(profile); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } + try { + const { profile } = await createQualityProfile(data); + if (this.state.parent) { + await changeProfileParent(profile.key, this.state.parent); } - ); + this.props.onCreate(profile); + } finally { + if (this.mounted) { + this.setState({ loading: false }); + } + } }; render() { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index e48687cfb53..8d1158f0c88 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -65,6 +65,7 @@ end_date=End Date edit=Edit events=Events example=Example +extend=Extend explore=Explore false=False favorite=Favorite @@ -1138,7 +1139,8 @@ quality_profiles.parent=Parent: quality_profiles.parameter_set_to=Parameter {0} set to {1} quality_profiles.x_rules_only_in={0} rules only in quality_profiles.x_rules_have_different_configuration={0} rules have a different configuration -quality_profiles.copy_x_title=Copy Profile {0} - {1} +quality_profiles.copy_x_title=Copy Profile "{0}" - {1} +quality_profiles.extend_x_title=Extend Profile "{0}" - {1} quality_profiles.copy_new_name=New name quality_profiles.rename_x_title=Rename Profile {0} - {1} quality_profiles.deprecated=deprecated -- 2.39.5