]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9392 Allow profile to be extended directly
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Tue, 8 Jan 2019 14:49:59 +0000 (15:49 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 16 Jan 2019 08:43:12 +0000 (09:43 +0100)
server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx
server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx
server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap
server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

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 (file)
index 0000000..6b8fd8d
--- /dev/null
@@ -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<Pick<State, 'name'>>;
+
+export default class ExtendProfileForm extends React.PureComponent<Props, State> {
+  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<HTMLInputElement>) => {
+    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 (
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
+        <form>
+          <div className="modal-head">
+            <h2>{header}</h2>
+          </div>
+          <div className="modal-body">
+            <div className="modal-field">
+              <label htmlFor="extend-profile-name">
+                {translate('quality_profiles.copy_new_name')}
+                <em className="mandatory">*</em>
+              </label>
+              <input
+                autoFocus={true}
+                id="extend-profile-name"
+                maxLength={100}
+                name="name"
+                onChange={this.handleNameChange}
+                required={true}
+                size={50}
+                type="text"
+                value={this.state.name ? this.state.name : ''}
+              />
+            </div>
+          </div>
+          <div className="modal-foot">
+            <DeferredSpinner className="spacer-right" loading={this.state.loading} />
+            <SubmitButton
+              disabled={this.state.loading || !this.canSubmit(this.state)}
+              id="extend-profile-submit"
+              onClick={this.handleFormSubmit}>
+              {translate('copy')}
+            </SubmitButton>
+            <ResetButtonLink id="extend-profile-cancel" onClick={this.props.onClose}>
+              {translate('cancel')}
+            </ResetButtonLink>
+          </div>
+        </form>
+      </Modal>
+    );
+  }
+}
index 25b13a13cf9462ea2f100a6bad381369211c2f97..e5d833fca089cc0cf61b3bba6069cab9acf860b0 100644 (file)
@@ -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<Props, State> {
   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<Props, State> {
     );
   };
 
-  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<Props, State> {
     );
   };
 
-  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<Props, State> {
           </ActionsDropdownItem>
 
           {actions.copy && (
-            <ActionsDropdownItem id="quality-profile-copy" onClick={this.handleCopyClick}>
-              {translate('copy')}
-            </ActionsDropdownItem>
+            <>
+              <ActionsDropdownItem id="quality-profile-copy" onClick={this.handleCopyClick}>
+                {translate('copy')}
+              </ActionsDropdownItem>
+
+              <ActionsDropdownItem id="quality-profile-extend" onClick={this.handleExtendClick}>
+                {translate('extend')}
+              </ActionsDropdownItem>
+            </>
           )}
 
           {actions.edit && (
@@ -195,6 +221,15 @@ export class ProfileActions extends React.PureComponent<Props, State> {
           />
         )}
 
+        {this.state.extendFormOpen && (
+          <ExtendProfileForm
+            onClose={this.closeExtendForm}
+            onExtend={this.handleProfileExtend}
+            organization={this.props.organization}
+            profile={profile}
+          />
+        )}
+
         {this.state.deleteFormOpen && (
           <DeleteProfileForm
             onClose={this.closeDeleteForm}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx
new file mode 100644 (file)
index 0000000..167a277
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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 { shallow } from 'enzyme';
+import ExtendProfileForm from '../ExtendProfileForm';
+import { createQualityProfile, changeProfileParent } from '../../../../api/quality-profiles';
+import { mockQualityProfile } from '../../testUtils';
+import { click } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../api/quality-profiles', () => ({
+  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<ExtendProfileForm['props']> = {}) {
+  return shallow(
+    <ExtendProfileForm
+      onClose={jest.fn()}
+      onExtend={jest.fn()}
+      organization="foo"
+      profile={mockQualityProfile()}
+      {...props}
+    />
+  );
+}
index e8f9318c739c1da335268e8a39a5b89c841a3140..8b4bfdd4c6bfb6025269c7a210eb9a3bbd2f8913 100644 (file)
@@ -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(
-      <ProfileActions
-        organization="org"
-        profile={PROFILE}
-        router={{ push: jest.fn(), replace: jest.fn() }}
-        updateProfiles={jest.fn()}
-      />
-    )
-  ).toMatchSnapshot();
+  expect(shallowRender()).toMatchSnapshot();
 });
 
 it('renders with permission to edit only', () => {
-  expect(
-    shallow(
-      <ProfileActions
-        organization="org"
-        profile={{ ...PROFILE, actions: { edit: true } }}
-        router={{ push: jest.fn(), replace: jest.fn() }}
-        updateProfiles={jest.fn()}
-      />
-    )
-  ).toMatchSnapshot();
+  expect(shallowRender({ profile: { ...PROFILE, actions: { edit: true } } })).toMatchSnapshot();
 });
 
 it('renders with all permissions', () => {
   expect(
-    shallow(
-      <ProfileActions
-        organization="org"
-        profile={{
-          ...PROFILE,
-          actions: {
-            copy: true,
-            edit: true,
-            delete: true,
-            setAsDefault: true,
-            associateProjects: true
-          }
-        }}
-        router={{ push: jest.fn(), replace: jest.fn() }}
-        updateProfiles={jest.fn()}
-      />
-    )
+    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(
-    <ProfileActions
-      organization="org"
-      profile={{ ...PROFILE, actions: { copy: true } }}
-      router={{ push, replace: jest.fn() }}
-      updateProfiles={updateProfiles}
-    />
-  );
+  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<Function>('onCopy')('new-name');
+  wrapper.find('CopyProfileForm').prop<Function>('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<Function>('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<ProfileActions['props']> = {}) {
+  const router = mockRouter();
+  return shallow(
+    <ProfileActions
+      organization="org"
+      profile={PROFILE}
+      router={router}
+      updateProfiles={jest.fn()}
+      {...props}
+    />
+  );
+}
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 (file)
index 0000000..f0dbf25
--- /dev/null
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Modal
+  contentLabel="quality_profiles.extend_x_title.name.JavaScript"
+  onRequestClose={[MockFunction]}
+>
+  <form>
+    <div
+      className="modal-head"
+    >
+      <h2>
+        quality_profiles.extend_x_title.name.JavaScript
+      </h2>
+    </div>
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-field"
+      >
+        <label
+          htmlFor="extend-profile-name"
+        >
+          quality_profiles.copy_new_name
+          <em
+            className="mandatory"
+          >
+            *
+          </em>
+        </label>
+        <input
+          autoFocus={true}
+          id="extend-profile-name"
+          maxLength={100}
+          name="name"
+          onChange={[Function]}
+          required={true}
+          size={50}
+          type="text"
+          value=""
+        />
+      </div>
+    </div>
+    <div
+      className="modal-foot"
+    >
+      <DeferredSpinner
+        className="spacer-right"
+        loading={false}
+        timeout={100}
+      />
+      <SubmitButton
+        disabled={true}
+        id="extend-profile-submit"
+        onClick={[Function]}
+      >
+        copy
+      </SubmitButton>
+      <ResetButtonLink
+        id="extend-profile-cancel"
+        onClick={[MockFunction]}
+      >
+        cancel
+      </ResetButtonLink>
+    </div>
+  </form>
+</Modal>
+`;
index 0578972611b75f9736a7bab6592129b6767f12a9..0066c498c61f61762adcbc1204098453bb6cae94 100644 (file)
@@ -44,6 +44,12 @@ exports[`renders with all permissions 1`] = `
     >
       copy
     </ActionsDropdownItem>
+    <ActionsDropdownItem
+      id="quality-profile-extend"
+      onClick={[Function]}
+    >
+      extend
+    </ActionsDropdownItem>
     <ActionsDropdownItem
       id="quality-profile-rename"
       onClick={[Function]}
index 56d1032bd404bc53504327289f9e9979404c54eb..876f85a88a506cffae0b401ab96146181ecbe0ba 100644 (file)
@@ -87,7 +87,7 @@ export default class CreateProfileForm extends React.PureComponent<Props, State>
     this.setState({ parent: option ? option.value : undefined });
   };
 
-  handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+  handleFormSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => {
     event.preventDefault();
 
     this.setState({ loading: true });
@@ -97,26 +97,17 @@ export default class CreateProfileForm extends React.PureComponent<Props, State>
       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() {
index e48687cfb53a47e7e5395a994594f7bb8b78470f..8d1158f0c88df5d84381f3f2ebe6bd4235c66a54 100644 (file)
@@ -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