]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11063 Add 'Always use the Default' option at project level for QP
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Fri, 18 Sep 2020 14:51:54 +0000 (16:51 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 8 Oct 2020 20:08:02 +0000 (20:08 +0000)
28 files changed:
server/sonar-web/src/main/js/api/quality-profiles.ts
server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/Header.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/ProfileRow.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/ProjectQualityProfilesApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/ProjectQualityProfilesAppRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/Table.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Header-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProfileRow-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProjectQualityProfilesApp-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProjectQualityProfilesAppRenderer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Table-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Header-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProfileRow-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProjectQualityProfilesApp-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProjectQualityProfilesAppRenderer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Table-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/constants.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityProfiles/routes.ts
server/sonar-web/src/main/js/apps/projectQualityProfiles/types.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 5131add5f746360ece42a22f5583a4550a51ac8b..a19c067ea5b37a6e2b57c4ec759c1627a190e50e 100644 (file)
@@ -69,7 +69,7 @@ export interface SearchQualityProfilesResponse {
 }
 
 export function searchQualityProfiles(
-  parameters: SearchQualityProfilesParameters
+  parameters?: SearchQualityProfilesParameters
 ): Promise<SearchQualityProfilesResponse> {
   return getJSON('/api/qualityprofiles/search', parameters).catch(throwGlobalError);
 }
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx
deleted file mode 100644 (file)
index 8fd4a17..0000000
+++ /dev/null
@@ -1,155 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 { Helmet } from 'react-helmet-async';
-import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
-import {
-  associateProject,
-  dissociateProject,
-  Profile,
-  searchQualityProfiles
-} from '../../api/quality-profiles';
-import A11ySkipTarget from '../../app/components/a11y/A11ySkipTarget';
-import Suggestions from '../../app/components/embed-docs-modal/Suggestions';
-import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage';
-import handleRequiredAuthorization from '../../app/utils/handleRequiredAuthorization';
-import Header from './Header';
-import Table from './Table';
-
-interface Props {
-  component: T.Component;
-}
-
-interface State {
-  allProfiles?: Profile[];
-  loading: boolean;
-  profiles?: Profile[];
-}
-
-export default class QualityProfiles extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { loading: true };
-
-  componentDidMount() {
-    this.mounted = true;
-    if (this.checkPermissions()) {
-      this.fetchProfiles();
-    } else {
-      handleRequiredAuthorization();
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  checkPermissions() {
-    const { configuration } = this.props.component;
-    const hasPermission = configuration && configuration.showQualityProfiles;
-    return !!hasPermission;
-  }
-
-  fetchProfiles() {
-    const { key, organization } = this.props.component;
-    Promise.all([
-      searchQualityProfiles({ organization }).then(r => r.profiles),
-      searchQualityProfiles({ organization, project: key }).then(r => r.profiles)
-    ]).then(
-      ([allProfiles, profiles]) => {
-        if (this.mounted) {
-          this.setState({ loading: false, allProfiles, profiles });
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      }
-    );
-  }
-
-  handleChangeProfile = (oldKey: string, newKey: string) => {
-    const { component } = this.props;
-    const { allProfiles, profiles } = this.state;
-    const oldProfile = allProfiles && allProfiles.find(profile => profile.key === oldKey);
-    const newProfile = allProfiles && allProfiles.find(profile => profile.key === newKey);
-
-    let request;
-
-    if (newProfile) {
-      if (newProfile.isDefault && oldProfile) {
-        request = dissociateProject(oldProfile, component.key);
-      } else {
-        request = associateProject(newProfile, component.key);
-      }
-    }
-
-    if (request) {
-      return request.then(() => {
-        if (this.mounted && profiles && newProfile) {
-          // remove old profile, add new one
-          const nextProfiles = [...profiles.filter(profile => profile.key !== oldKey), newProfile];
-          this.setState({ profiles: nextProfiles });
-
-          addGlobalSuccessMessage(
-            translateWithParameters(
-              'project_quality_profile.successfully_updated',
-              newProfile.languageName
-            )
-          );
-        }
-      });
-    } else {
-      return Promise.resolve();
-    }
-  };
-
-  render() {
-    if (!this.checkPermissions()) {
-      return null;
-    }
-
-    const { allProfiles, loading, profiles } = this.state;
-
-    return (
-      <div className="page page-limited">
-        <Suggestions suggestions="project_quality_profiles" />
-        <Helmet defer={false} title={translate('project_quality_profiles.page')} />
-
-        <A11ySkipTarget anchor="profiles_main" />
-
-        <Header />
-
-        {loading ? (
-          <i className="spinner" />
-        ) : (
-          allProfiles &&
-          profiles && (
-            <Table
-              allProfiles={allProfiles}
-              onChangeProfile={this.handleChangeProfile}
-              profiles={profiles}
-            />
-          )
-        )}
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/Header.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/Header.tsx
deleted file mode 100644 (file)
index 10f8741..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip';
-import { translate } from 'sonar-ui-common/helpers/l10n';
-
-export default function Header() {
-  return (
-    <header className="page-header">
-      <div className="page-title display-flex-center">
-        <h1>{translate('project_quality_profiles.page')}</h1>
-        <HelpTooltip
-          className="spacer-left"
-          overlay={
-            <div className="big-padded-top big-padded-bottom">
-              {translate('quality_profiles.list.projects.help')}
-            </div>
-          }
-        />
-      </div>
-      <div className="page-description">
-        {translate('project_quality_profiles.page.description')}
-      </div>
-    </header>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProfileRow.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProfileRow.tsx
deleted file mode 100644 (file)
index 5fc11fa..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 Select from 'sonar-ui-common/components/controls/Select';
-import { translate } from 'sonar-ui-common/helpers/l10n';
-import { Profile } from '../../api/quality-profiles';
-
-interface Props {
-  onChangeProfile: (oldProfile: string, newProfile: string) => Promise<void>;
-  possibleProfiles: Profile[];
-  profile: Profile;
-}
-
-interface State {
-  loading: boolean;
-}
-
-export default class ProfileRow extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { loading: false };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  stopLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  handleChange = (option: { value: string }) => {
-    if (this.props.profile.key !== option.value) {
-      this.setState({ loading: true });
-      this.props
-        .onChangeProfile(this.props.profile.key, option.value)
-        .then(this.stopLoading, this.stopLoading);
-    }
-  };
-
-  renderProfileName = (profileOption: { isDefault: boolean; label: string }) => {
-    if (profileOption.isDefault) {
-      return (
-        <span>
-          <strong>{translate('default')}</strong>
-          {': '}
-          {profileOption.label}
-        </span>
-      );
-    }
-
-    return <span>{profileOption.label}</span>;
-  };
-
-  renderProfileSelect() {
-    const { profile, possibleProfiles } = this.props;
-
-    const options = possibleProfiles.map(profile => ({
-      value: profile.key,
-      label: profile.name,
-      isDefault: profile.isDefault
-    }));
-
-    return (
-      <Select
-        clearable={false}
-        disabled={this.state.loading}
-        onChange={this.handleChange}
-        optionRenderer={this.renderProfileName}
-        options={options}
-        style={{ width: 300 }}
-        value={profile.key}
-        valueRenderer={this.renderProfileName}
-      />
-    );
-  }
-
-  render() {
-    const { profile } = this.props;
-
-    return (
-      <tr data-key={profile.language}>
-        <td className="thin nowrap">{profile.languageName}</td>
-        <td className="thin nowrap">{this.renderProfileSelect()}</td>
-        <td>{this.state.loading && <i className="spinner" />}</td>
-      </tr>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProjectQualityProfilesApp.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProjectQualityProfilesApp.tsx
new file mode 100644 (file)
index 0000000..0372d7d
--- /dev/null
@@ -0,0 +1,292 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { differenceBy } from 'lodash';
+import * as React from 'react';
+import { translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { isDefined } from 'sonar-ui-common/helpers/types';
+import {
+  associateProject,
+  dissociateProject,
+  getProfileProjects,
+  Profile,
+  searchQualityProfiles
+} from '../../api/quality-profiles';
+import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage';
+import handleRequiredAuthorization from '../../app/utils/handleRequiredAuthorization';
+import ProjectQualityProfilesAppRenderer from './ProjectQualityProfilesAppRenderer';
+import { ProjectProfile } from './types';
+
+interface Props {
+  component: T.Component;
+}
+
+interface State {
+  allProfiles?: Profile[];
+  loading: boolean;
+  projectProfiles?: ProjectProfile[];
+  showAddLanguageModal?: boolean;
+  showProjectProfileInModal?: ProjectProfile;
+}
+
+export default class ProjectQualityProfilesApp extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { loading: true };
+
+  componentDidMount() {
+    this.mounted = true;
+    if (this.checkPermissions()) {
+      this.fetchProfiles();
+    } else {
+      handleRequiredAuthorization();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  checkPermissions() {
+    const { configuration } = this.props.component;
+    const hasPermission = configuration && configuration.showQualityProfiles;
+    return !!hasPermission;
+  }
+
+  fetchProfiles = async () => {
+    const { component } = this.props;
+
+    const allProfiles = await searchQualityProfiles()
+      .then(({ profiles }) => profiles)
+      .catch(() => [] as Profile[]);
+
+    // We need to know if a profile was explicitly assigned to a project,
+    // even if it's the system default. For this, we need to fetch the info
+    // for each existing profile. We only keep those that were effectively
+    // selected, and discard the rest.
+    const projectProfiles = await Promise.all(
+      allProfiles.map(profile =>
+        getProfileProjects({
+          key: profile.key,
+          q: component.name,
+          selected: 'selected'
+        })
+          .then(({ results }) => ({
+            selected: Boolean(results.find(p => p.key === component.key)?.selected),
+            profile
+          }))
+          .catch(() => ({ selected: false, profile }))
+      )
+    );
+
+    const selectedProjectProfiles = projectProfiles
+      .filter(({ selected }) => selected)
+      .map(({ profile }) => ({
+        profile,
+        selected: true
+      }));
+
+    // Finally, the project uses some profiles implicitly, either inheriting
+    // from the system defaults, OR because the project wasn't re-analyzed
+    // yet (in which case the info is outdated). We also need this information.
+    const componentProfiles = differenceBy(
+      component.qualityProfiles,
+      selectedProjectProfiles.map(p => p.profile),
+      'key'
+    )
+      // Discard languages we already have up-to-date info for.
+      .filter(({ language }) => !selectedProjectProfiles.some(p => p.profile.language === language))
+      .map(({ key }) => {
+        const profile = allProfiles.find(p => p.key === key);
+        if (profile) {
+          // If the profile is the default profile, all is good.
+          if (profile.isDefault) {
+            return { profile, selected: false };
+          } else {
+            // If it is neither the default, nor explicitly selected, it
+            // means this is outdated information. This can only mean the
+            // user wants to use the default profile, but it will only
+            // be taken into account after a new analysis. Fetch the
+            // default profile.
+            const defaultProfile = allProfiles.find(
+              p => p.isDefault && p.language === profile.language
+            );
+            return (
+              defaultProfile && {
+                profile: defaultProfile,
+                selected: false
+              }
+            );
+          }
+        } else {
+          return undefined;
+        }
+      })
+      .filter(isDefined);
+
+    if (this.mounted) {
+      this.setState({
+        allProfiles,
+        projectProfiles: [...selectedProjectProfiles, ...componentProfiles],
+        loading: false
+      });
+    }
+  };
+
+  handleOpenSetProfileModal = (showProjectProfileInModal: ProjectProfile) => {
+    this.setState({ showProjectProfileInModal });
+  };
+
+  handleOpenAddLanguageModal = () => {
+    this.setState({ showAddLanguageModal: true });
+  };
+
+  handleCloseModal = () => {
+    this.setState({ showAddLanguageModal: false, showProjectProfileInModal: undefined });
+  };
+
+  handleAddLanguage = async (key: string) => {
+    const { component } = this.props;
+    const { allProfiles = [] } = this.state;
+    const newProfile = allProfiles.find(p => p.key === key);
+
+    if (newProfile) {
+      try {
+        await associateProject(newProfile, component.key);
+
+        if (this.mounted) {
+          this.setState(({ projectProfiles = [] }) => {
+            const newProjectProfiles = [
+              ...projectProfiles,
+              {
+                profile: newProfile,
+                selected: true
+              }
+            ];
+
+            return { projectProfiles: newProjectProfiles, showAddLanguageModal: false };
+          });
+
+          addGlobalSuccessMessage(
+            translateWithParameters(
+              'project_quality_profile.successfully_updated',
+              newProfile.languageName
+            )
+          );
+        }
+      } catch (e) {
+        if (this.mounted) {
+          this.setState({ showAddLanguageModal: false });
+        }
+      }
+    }
+  };
+
+  handleSetProfile = async (newKey: string | undefined, oldKey: string) => {
+    const { component } = this.props;
+    const { allProfiles = [], projectProfiles = [] } = this.state;
+
+    const newProfile = newKey && allProfiles.find(p => p.key === newKey);
+    const oldProjectProfile = projectProfiles.find(p => p.profile.key === oldKey);
+    const defaultProfile = allProfiles.find(
+      p => p.isDefault && p.language === oldProjectProfile?.profile.language
+    );
+
+    if (defaultProfile === undefined || oldProjectProfile === undefined) {
+      // Isn't possible. We're in a messed up state.
+      return;
+    }
+
+    let replaceProfile: Profile | undefined;
+    if (newProfile) {
+      replaceProfile = newProfile;
+
+      // Associate with the new profile.
+      try {
+        await associateProject(newProfile, component.key);
+      } catch (e) {
+        // Something went wrong. Keep the old profile in the UI.
+        replaceProfile = oldProjectProfile.profile;
+      }
+    } else if (newKey === undefined) {
+      replaceProfile = defaultProfile;
+
+      // We want to use the system default. Explicitly dissociate the project
+      // profile, if it was explicitly selected.
+      if (oldProjectProfile.selected) {
+        try {
+          await dissociateProject(oldProjectProfile.profile, component.key);
+        } catch (e) {
+          // Something went wrong. Keep the old profile in the UI.
+          replaceProfile = oldProjectProfile.profile;
+        }
+      }
+    }
+
+    if (this.mounted) {
+      const newProjectProfiles = [
+        // Remove the old profile.
+        ...projectProfiles.filter(p => p.profile.key !== oldKey),
+        // Replace with the "new" profile.
+        replaceProfile && {
+          profile: replaceProfile,
+          selected: newKey !== undefined
+        }
+      ].filter(isDefined);
+
+      this.setState({ projectProfiles: newProjectProfiles, showProjectProfileInModal: undefined });
+
+      addGlobalSuccessMessage(
+        translateWithParameters(
+          'project_quality_profile.successfully_updated',
+          defaultProfile.languageName
+        )
+      );
+    }
+  };
+
+  render() {
+    if (!this.checkPermissions()) {
+      return null;
+    }
+
+    const {
+      allProfiles,
+      loading,
+      showProjectProfileInModal,
+      projectProfiles,
+      showAddLanguageModal
+    } = this.state;
+
+    return (
+      <ProjectQualityProfilesAppRenderer
+        allProfiles={allProfiles}
+        component={this.props.component}
+        loading={loading}
+        onAddLanguage={this.handleAddLanguage}
+        onCloseModal={this.handleCloseModal}
+        onOpenAddLanguageModal={this.handleOpenAddLanguageModal}
+        onOpenSetProfileModal={this.handleOpenSetProfileModal}
+        onSetProfile={this.handleSetProfile}
+        projectProfiles={projectProfiles}
+        showAddLanguageModal={showAddLanguageModal}
+        showProjectProfileInModal={showProjectProfileInModal}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProjectQualityProfilesAppRenderer.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProjectQualityProfilesAppRenderer.tsx
new file mode 100644 (file)
index 0000000..154aa4d
--- /dev/null
@@ -0,0 +1,187 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { groupBy, orderBy } from 'lodash';
+import * as React from 'react';
+import { Helmet } from 'react-helmet-async';
+import { Link } from 'react-router';
+import { Button } from 'sonar-ui-common/components/controls/buttons';
+import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip';
+import EditIcon from 'sonar-ui-common/components/icons/EditIcon';
+import PlusCircleIcon from 'sonar-ui-common/components/icons/PlusCircleIcon';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { Profile } from '../../api/quality-profiles';
+import A11ySkipTarget from '../../app/components/a11y/A11ySkipTarget';
+import Suggestions from '../../app/components/embed-docs-modal/Suggestions';
+import { getRulesUrl } from '../../helpers/urls';
+import BuiltInQualityProfileBadge from '../quality-profiles/components/BuiltInQualityProfileBadge';
+import AddLanguageModal from './components/AddLanguageModal';
+import SetQualityProfileModal from './components/SetQualityProfileModal';
+import { ProjectProfile } from './types';
+
+export interface ProjectQualityProfilesAppRendererProps {
+  allProfiles?: Profile[];
+  component: T.Component;
+  loading: boolean;
+  onAddLanguage: (key: string) => Promise<void>;
+  onCloseModal: () => void;
+  onOpenAddLanguageModal: () => void;
+  onOpenSetProfileModal: (projectProfile: ProjectProfile) => void;
+  onSetProfile: (newKey: string | undefined, oldKey: string) => Promise<void>;
+  projectProfiles?: ProjectProfile[];
+  showAddLanguageModal?: boolean;
+  showProjectProfileInModal?: ProjectProfile;
+}
+
+export default function ProjectQualityProfilesAppRenderer(
+  props: ProjectQualityProfilesAppRendererProps
+) {
+  const {
+    allProfiles,
+    component,
+    loading,
+    showProjectProfileInModal,
+    projectProfiles,
+    showAddLanguageModal
+  } = props;
+
+  const profilesByLanguage = groupBy(allProfiles, 'language');
+  const orderedProfiles = orderBy(projectProfiles, p => p.profile.languageName);
+
+  return (
+    <div className="page page-limited" id="project-quality-profiles">
+      <Suggestions suggestions="project_quality_profiles" />
+      <Helmet defer={false} title={translate('project_quality_profiles.page')} />
+      <A11ySkipTarget anchor="profiles_main" />
+
+      <header className="page-header">
+        <div className="page-title display-flex-center">
+          <h1>{translate('project_quality_profiles.page')} </h1>
+          <HelpTooltip
+            className="spacer-left"
+            overlay={
+              <div className="big-padded-top big-padded-bottom">
+                {translate('quality_profiles.list.projects.help')}
+              </div>
+            }
+          />
+        </div>
+      </header>
+
+      <div className="boxed-group">
+        <h2 className="boxed-group-header">{translate('project_quality_profile.subtitle')}</h2>
+
+        <div className="boxed-group-inner">
+          <p className="big-spacer-bottom">
+            {translate('project_quality_profiles.page.description')}
+          </p>
+
+          {loading && <i className="spinner spacer-left" />}
+
+          {!loading && orderedProfiles.length > 0 && (
+            <table className="data zebra">
+              <thead>
+                <tr>
+                  <th>{translate('language')}</th>
+                  <th className="thin nowrap">{translate('project_quality_profile.current')}</th>
+                  <th className="thin nowrap text-right">
+                    {translate('coding_rules.filters.activation.active_rules')}
+                  </th>
+                  <th aria-label={translate('actions')} />
+                </tr>
+              </thead>
+              <tbody>
+                {orderedProfiles.map(projectProfile => {
+                  const { profile, selected } = projectProfile;
+                  return (
+                    <tr key={profile.language}>
+                      <td>{profile.languageName}</td>
+                      <td className="thin nowrap">
+                        <span className="display-inline-flex-center">
+                          {!selected && profile.isDefault ? (
+                            <em>{translate('project_quality_profile.instance_default')}</em>
+                          ) : (
+                            <>
+                              {profile.name}
+                              {profile.isBuiltIn && (
+                                <BuiltInQualityProfileBadge className="spacer-left" />
+                              )}
+                            </>
+                          )}
+                        </span>
+                      </td>
+                      <td className="nowrap text-right">
+                        <Link to={getRulesUrl({ qprofile: profile.key })}>
+                          {profile.activeRuleCount}
+                        </Link>
+                      </td>
+                      <td className="text-right">
+                        <Button
+                          onClick={() => {
+                            props.onOpenSetProfileModal(projectProfile);
+                          }}>
+                          <EditIcon className="spacer-right" />
+                          {translate('project_quality_profile.change_profile')}
+                        </Button>
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+          )}
+
+          <div className="big-spacer-top">
+            <h2>{translate('project_quality_profile.add_language.title')}</h2>
+
+            <p className="spacer-top big-spacer-bottom">
+              {translate('project_quality_profile.add_language.description')}
+            </p>
+
+            <Button disabled={loading} onClick={props.onOpenAddLanguageModal}>
+              <PlusCircleIcon className="little-spacer-right" />
+              {translate('project_quality_profile.add_language.action')}
+            </Button>
+          </div>
+
+          {showProjectProfileInModal && (
+            <SetQualityProfileModal
+              availableProfiles={profilesByLanguage[showProjectProfileInModal.profile.language]}
+              component={component}
+              currentProfile={showProjectProfileInModal.profile}
+              onClose={props.onCloseModal}
+              onSubmit={props.onSetProfile}
+              usesDefault={!showProjectProfileInModal.selected}
+            />
+          )}
+
+          {showAddLanguageModal && projectProfiles && (
+            <AddLanguageModal
+              profilesByLanguage={profilesByLanguage}
+              onClose={props.onCloseModal}
+              onSubmit={props.onAddLanguage}
+              unavailableLanguages={projectProfiles.map(p => p.profile.language)}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/Table.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/Table.tsx
deleted file mode 100644 (file)
index f5617bf..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 { groupBy, orderBy } from 'lodash';
-import * as React from 'react';
-import { translate } from 'sonar-ui-common/helpers/l10n';
-import { Profile } from '../../api/quality-profiles';
-import ProfileRow from './ProfileRow';
-
-interface Props {
-  allProfiles: Profile[];
-  profiles: Profile[];
-  onChangeProfile: (oldProfile: string, newProfile: string) => Promise<void>;
-}
-
-export default function Table(props: Props) {
-  const profilesByLanguage = groupBy(props.allProfiles, 'language');
-  const orderedProfiles = orderBy(props.profiles, 'languageName');
-
-  // set key to language to avoid destroying of component
-  const profileRows = orderedProfiles.map(profile => (
-    <ProfileRow
-      key={profile.language}
-      onChangeProfile={props.onChangeProfile}
-      possibleProfiles={profilesByLanguage[profile.language]}
-      profile={profile}
-    />
-  ));
-
-  return (
-    <div className="boxed-group boxed-group-inner">
-      <table className="data zebra">
-        <thead>
-          <tr>
-            <th className="thin nowrap">{translate('language')}</th>
-            <th className="thin nowrap">{translate('quality_profile')}</th>
-            {/* keep one empty cell for the spinner */}
-            <th>&nbsp;</th>
-          </tr>
-        </thead>
-        <tbody>{profileRows}</tbody>
-      </table>
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx
deleted file mode 100644 (file)
index b76184b..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import {
-  associateProject,
-  dissociateProject,
-  searchQualityProfiles
-} from '../../../api/quality-profiles';
-import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization';
-import { mockComponent, mockQualityProfile } from '../../../helpers/testMocks';
-import App from '../App';
-import Table from '../Table';
-
-beforeEach(() => jest.clearAllMocks());
-
-jest.mock('../../../api/quality-profiles', () => ({
-  associateProject: jest.fn().mockResolvedValue({}),
-  dissociateProject: jest.fn().mockResolvedValue({}),
-  searchQualityProfiles: jest.fn().mockResolvedValue({})
-}));
-
-jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({
-  default: jest.fn()
-}));
-
-jest.mock('../../../app/utils/handleRequiredAuthorization', () => ({
-  default: jest.fn()
-}));
-
-const component = mockComponent({ configuration: { showQualityProfiles: true } });
-
-it('checks permissions', () => {
-  shallowRender({ component: { ...component, configuration: undefined } });
-  expect(handleRequiredAuthorization).toBeCalled();
-});
-
-it('fetches profiles', () => {
-  shallowRender();
-  expect(searchQualityProfiles).toHaveBeenCalledTimes(2);
-  expect(searchQualityProfiles).toBeCalledWith({ organization: component.organization });
-  expect(searchQualityProfiles).toBeCalledWith({
-    organization: component.organization,
-    project: component.key
-  });
-});
-
-it('changes profile', () => {
-  const wrapper = shallowRender();
-
-  const fooJava = mockQualityProfile({ key: 'foo-java', language: 'java' });
-  const fooJs = mockQualityProfile({ key: 'foo-js', language: 'js' });
-  const bar = mockQualityProfile({ key: 'bar-java', language: 'java' });
-  const baz = mockQualityProfile({ key: 'baz-java', language: 'java', isDefault: true });
-  const allProfiles = [fooJava, bar, baz, fooJs];
-  const profiles = [fooJava, fooJs];
-  wrapper.setState({ allProfiles, loading: false, profiles });
-
-  wrapper
-    .find(Table)
-    .props()
-    .onChangeProfile(fooJava.key, bar.key);
-  expect(associateProject).toBeCalledWith(bar, component.key);
-
-  wrapper
-    .find(Table)
-    .props()
-    .onChangeProfile(fooJava.key, baz.key);
-  expect(dissociateProject).toBeCalledWith(fooJava, component.key);
-});
-
-function shallowRender(props: Partial<App['props']> = {}) {
-  return shallow<App>(<App component={component} {...props} />);
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Header-test.tsx
deleted file mode 100644 (file)
index 9730d14..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import Header from '../Header';
-
-it('renders', () => {
-  expect(shallow(<Header />)).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProfileRow-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProfileRow-test.tsx
deleted file mode 100644 (file)
index fe384a2..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import ProfileRow from '../ProfileRow';
-
-it('renders', () => {
-  expect(
-    shallow(
-      <ProfileRow
-        onChangeProfile={jest.fn()}
-        possibleProfiles={[randomProfile('bar'), randomProfile('baz')]}
-        profile={randomProfile('foo')}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-it('changes profile', async () => {
-  const onChangeProfile = jest.fn(() => Promise.resolve());
-  const wrapper = shallow<ProfileRow>(
-    <ProfileRow
-      onChangeProfile={onChangeProfile}
-      possibleProfiles={[randomProfile('bar'), randomProfile('baz')]}
-      profile={randomProfile('foo')}
-    />
-  );
-  (wrapper.instance() as ProfileRow).mounted = true;
-  wrapper.find('Select').prop<Function>('onChange')({ value: 'baz' });
-  expect(onChangeProfile).toBeCalledWith('foo', 'baz');
-  expect(wrapper.state().loading).toBe(true);
-  await new Promise(setImmediate);
-  expect(wrapper.state().loading).toBe(false);
-});
-
-function randomProfile(key: string) {
-  return {
-    activeRuleCount: 17,
-    activeDeprecatedRuleCount: 0,
-    key,
-    name: key,
-    language: 'xoo',
-    languageName: 'xoo',
-    organization: 'org'
-  };
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProjectQualityProfilesApp-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProjectQualityProfilesApp-test.tsx
new file mode 100644 (file)
index 0000000..bf5a00d
--- /dev/null
@@ -0,0 +1,239 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import {
+  associateProject,
+  dissociateProject,
+  getProfileProjects,
+  ProfileProject,
+  searchQualityProfiles
+} from '../../../api/quality-profiles';
+import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization';
+import { mockComponent } from '../../../helpers/testMocks';
+import ProjectQualityProfilesApp from '../ProjectQualityProfilesApp';
+
+jest.mock('../../../api/quality-profiles', () => {
+  const { mockQualityProfile } = jest.requireActual('../../../helpers/testMocks');
+
+  return {
+    associateProject: jest.fn().mockResolvedValue({}),
+    dissociateProject: jest.fn().mockResolvedValue({}),
+    searchQualityProfiles: jest.fn().mockResolvedValue({
+      profiles: [
+        mockQualityProfile({ key: 'css', language: 'css' }),
+        mockQualityProfile({ key: 'css2', language: 'css' }),
+        mockQualityProfile({ key: 'css_default', language: 'css', isDefault: true }),
+        mockQualityProfile({ key: 'java', language: 'java' }),
+        mockQualityProfile({ key: 'java_default', language: 'java', isDefault: true }),
+        mockQualityProfile({ key: 'js', language: 'js' }),
+        mockQualityProfile({ key: 'js_default', language: 'js', isDefault: true }),
+        mockQualityProfile({ key: 'ts_default', language: 'ts', isDefault: true }),
+        mockQualityProfile({ key: 'html', language: 'html' }),
+        mockQualityProfile({ key: 'html_default', language: 'html', isDefault: true })
+      ]
+    }),
+    getProfileProjects: jest.fn(({ key }) => {
+      const results: ProfileProject[] = [];
+      if (key === 'js' || key === 'css' || key === 'html_default') {
+        results.push({
+          id: 1,
+          key: 'foo',
+          name: 'Foo',
+          selected: true
+        });
+      } else if (key === 'html') {
+        results.push({
+          id: 2,
+          key: 'foobar',
+          name: 'FooBar',
+          selected: true
+        });
+      }
+      return Promise.resolve({ results });
+    })
+  };
+});
+
+jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({
+  default: jest.fn()
+}));
+
+jest.mock('../../../app/utils/handleRequiredAuthorization', () => ({
+  default: jest.fn()
+}));
+
+beforeEach(jest.clearAllMocks);
+
+it('renders correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('correctly checks permissions', () => {
+  const wrapper = shallowRender({
+    component: mockComponent({ configuration: { showQualityProfiles: false } })
+  });
+  expect(wrapper.type()).toBeNull();
+  expect(handleRequiredAuthorization).toBeCalled();
+});
+
+it('correctly fetches and treats profile data', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(searchQualityProfiles).toBeCalled();
+  expect(getProfileProjects).toBeCalledTimes(10);
+
+  expect(wrapper.state().projectProfiles).toEqual([
+    expect.objectContaining({
+      profile: expect.objectContaining({ key: 'css' }),
+      selected: true
+    }),
+    expect.objectContaining({
+      profile: expect.objectContaining({ key: 'js' }),
+      selected: true
+    }),
+    expect.objectContaining({
+      profile: expect.objectContaining({ key: 'html_default' }),
+      selected: true
+    }),
+    expect.objectContaining({
+      profile: expect.objectContaining({ key: 'ts_default' }),
+      selected: false
+    })
+  ]);
+});
+
+it('correctly sets a profile', async () => {
+  const wrapper = shallowRender();
+  const instance = wrapper.instance();
+  await waitAndUpdate(wrapper);
+
+  // Dissociate a selected profile.
+  instance.handleSetProfile(undefined, 'css');
+  expect(dissociateProject).toHaveBeenLastCalledWith(
+    expect.objectContaining({ key: 'css' }),
+    'foo'
+  );
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().projectProfiles).toEqual(
+    expect.arrayContaining([
+      {
+        profile: expect.objectContaining({ key: 'css_default' }),
+        // It's not explicitly selected, as we're inheriting the default.
+        selected: false
+      }
+    ])
+  );
+
+  // Associate a new profile.
+  instance.handleSetProfile('css2', 'css_default');
+  expect(associateProject).toHaveBeenLastCalledWith(
+    expect.objectContaining({ key: 'css2' }),
+    'foo'
+  );
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().projectProfiles).toEqual(
+    expect.arrayContaining([
+      {
+        profile: expect.objectContaining({ key: 'css2' }),
+        // It's explicitly selected.
+        selected: true
+      }
+    ])
+  );
+
+  // Dissociate a default profile that was inherited.
+  (dissociateProject as jest.Mock).mockClear();
+  instance.handleSetProfile(undefined, 'ts_default');
+  // It won't call the WS.
+  expect(dissociateProject).not.toBeCalled();
+
+  // Associate a default profile that was already inherited.
+  instance.handleSetProfile('ts_default', 'ts_default');
+  expect(associateProject).toHaveBeenLastCalledWith(
+    expect.objectContaining({ key: 'ts_default' }),
+    'foo'
+  );
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().projectProfiles).toEqual(
+    expect.arrayContaining([
+      {
+        profile: expect.objectContaining({ key: 'ts_default' }),
+        // It's explicitly selected, even though it is the default profile.
+        selected: true
+      }
+    ])
+  );
+});
+
+it('correctly adds a new language', async () => {
+  const wrapper = shallowRender();
+  const instance = wrapper.instance();
+  await waitAndUpdate(wrapper);
+
+  instance.handleAddLanguage('java');
+  expect(associateProject).toHaveBeenLastCalledWith(
+    expect.objectContaining({ key: 'java' }),
+    'foo'
+  );
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().projectProfiles).toEqual(
+    expect.arrayContaining([
+      {
+        profile: expect.objectContaining({ key: 'java' }),
+        // It must be explicitly selected. Adding an unanalyzed language can
+        // only happen by explicitly choosing a profile.
+        selected: true
+      }
+    ])
+  );
+});
+
+it('correctly handles WS errors', async () => {
+  (searchQualityProfiles as jest.Mock).mockRejectedValueOnce(null);
+  (getProfileProjects as jest.Mock).mockRejectedValueOnce(null);
+
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state().allProfiles).toHaveLength(0);
+  expect(wrapper.state().projectProfiles).toHaveLength(0);
+  expect(wrapper.state().loading).toBe(false);
+});
+
+function shallowRender(props: Partial<ProjectQualityProfilesApp['props']> = {}) {
+  return shallow<ProjectQualityProfilesApp>(
+    <ProjectQualityProfilesApp
+      component={mockComponent({
+        key: 'foo',
+        configuration: { showQualityProfiles: true },
+        qualityProfiles: [
+          { key: 'css2', name: 'CSS 2', language: 'css' },
+          { key: 'js', name: 'JS', language: 'js' },
+          { key: 'ts_default', name: 'TS (default)', language: 'ts' },
+          { key: 'html', name: 'HTML', language: 'html' }
+        ]
+      })}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProjectQualityProfilesAppRenderer-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProjectQualityProfilesAppRenderer-test.tsx
new file mode 100644 (file)
index 0000000..cca8f75
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockComponent, mockQualityProfile } from '../../../helpers/testMocks';
+import ProjectQualityProfilesAppRenderer, {
+  ProjectQualityProfilesAppRendererProps
+} from '../ProjectQualityProfilesAppRenderer';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
+  expect(
+    shallowRender({
+      showProjectProfileInModal: {
+        profile: mockQualityProfile({ key: 'foo', language: 'js' }),
+        selected: false
+      }
+    })
+  ).toMatchSnapshot('open profile');
+  expect(shallowRender({ showAddLanguageModal: true })).toMatchSnapshot('add language');
+});
+
+function shallowRender(props: Partial<ProjectQualityProfilesAppRendererProps> = {}) {
+  return shallow<ProjectQualityProfilesAppRendererProps>(
+    <ProjectQualityProfilesAppRenderer
+      allProfiles={[
+        mockQualityProfile({ key: 'foo', language: 'js' }),
+        mockQualityProfile({ key: 'bar', language: 'css' }),
+        mockQualityProfile({ key: 'baz', language: 'html' })
+      ]}
+      component={mockComponent()}
+      loading={false}
+      onAddLanguage={jest.fn()}
+      onCloseModal={jest.fn()}
+      onOpenAddLanguageModal={jest.fn()}
+      onOpenSetProfileModal={jest.fn()}
+      onSetProfile={jest.fn()}
+      projectProfiles={[
+        {
+          profile: mockQualityProfile({
+            key: 'foo',
+            name: 'Foo',
+            isDefault: true,
+            language: 'js',
+            languageName: 'JS'
+          }),
+          selected: false
+        },
+        {
+          profile: mockQualityProfile({
+            key: 'bar',
+            name: 'Bar',
+            isDefault: true,
+            language: 'css',
+            languageName: 'CSS'
+          }),
+          selected: false
+        },
+        {
+          profile: mockQualityProfile({
+            key: 'baz',
+            name: 'Baz',
+            language: 'html',
+            languageName: 'HTML'
+          }),
+          selected: true
+        }
+      ]}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Table-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Table-test.tsx
deleted file mode 100644 (file)
index 0da9568..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import Table from '../Table';
-
-it('renders', () => {
-  const fooJava = randomProfile('foo-java', 'java');
-  const fooJs = randomProfile('foo-js', 'js');
-  const allProfiles = [
-    fooJava,
-    randomProfile('bar-java', 'java'),
-    randomProfile('baz-java', 'java'),
-    fooJs
-  ];
-  const profiles = [fooJava, fooJs];
-  expect(
-    shallow(<Table allProfiles={allProfiles} onChangeProfile={jest.fn()} profiles={profiles} />)
-  ).toMatchSnapshot();
-});
-
-function randomProfile(key: string, language: string) {
-  return {
-    activeRuleCount: 17,
-    activeDeprecatedRuleCount: 0,
-    key,
-    name: key,
-    language,
-    languageName: language,
-    organization: 'org'
-  };
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Header-test.tsx.snap
deleted file mode 100644 (file)
index 6ab3596..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders 1`] = `
-<header
-  className="page-header"
->
-  <div
-    className="page-title display-flex-center"
-  >
-    <h1>
-      project_quality_profiles.page
-    </h1>
-    <HelpTooltip
-      className="spacer-left"
-      overlay={
-        <div
-          className="big-padded-top big-padded-bottom"
-        >
-          quality_profiles.list.projects.help
-        </div>
-      }
-    />
-  </div>
-  <div
-    className="page-description"
-  >
-    project_quality_profiles.page.description
-  </div>
-</header>
-`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProfileRow-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProfileRow-test.tsx.snap
deleted file mode 100644 (file)
index 5729b3d..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders 1`] = `
-<tr
-  data-key="xoo"
->
-  <td
-    className="thin nowrap"
-  >
-    xoo
-  </td>
-  <td
-    className="thin nowrap"
-  >
-    <Select
-      clearable={false}
-      disabled={false}
-      onChange={[Function]}
-      optionRenderer={[Function]}
-      options={
-        Array [
-          Object {
-            "isDefault": undefined,
-            "label": "bar",
-            "value": "bar",
-          },
-          Object {
-            "isDefault": undefined,
-            "label": "baz",
-            "value": "baz",
-          },
-        ]
-      }
-      style={
-        Object {
-          "width": 300,
-        }
-      }
-      value="foo"
-      valueRenderer={[Function]}
-    />
-  </td>
-  <td />
-</tr>
-`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProjectQualityProfilesApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProjectQualityProfilesApp-test.tsx.snap
new file mode 100644 (file)
index 0000000..ff4aaa0
--- /dev/null
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+<ProjectQualityProfilesAppRenderer
+  component={
+    Object {
+      "breadcrumbs": Array [],
+      "configuration": Object {
+        "showQualityProfiles": true,
+      },
+      "key": "foo",
+      "name": "MyProject",
+      "organization": "foo",
+      "qualifier": "TRK",
+      "qualityGate": Object {
+        "isDefault": true,
+        "key": "30",
+        "name": "Sonar way",
+      },
+      "qualityProfiles": Array [
+        Object {
+          "key": "css2",
+          "language": "css",
+          "name": "CSS 2",
+        },
+        Object {
+          "key": "js",
+          "language": "js",
+          "name": "JS",
+        },
+        Object {
+          "key": "ts_default",
+          "language": "ts",
+          "name": "TS (default)",
+        },
+        Object {
+          "key": "html",
+          "language": "html",
+          "name": "HTML",
+        },
+      ],
+      "tags": Array [],
+    }
+  }
+  loading={true}
+  onAddLanguage={[Function]}
+  onCloseModal={[Function]}
+  onOpenAddLanguageModal={[Function]}
+  onOpenSetProfileModal={[Function]}
+  onSetProfile={[Function]}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProjectQualityProfilesAppRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProjectQualityProfilesAppRenderer-test.tsx.snap
new file mode 100644 (file)
index 0000000..5751380
--- /dev/null
@@ -0,0 +1,958 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: add language 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-profiles"
+>
+  <Suggestions
+    suggestions="project_quality_profiles"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_profiles.page"
+  />
+  <A11ySkipTarget
+    anchor="profiles_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_profiles.page
+         
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_profiles.list.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_profile.subtitle
+    </h2>
+    <div
+      className="boxed-group-inner"
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_profiles.page.description
+      </p>
+      <table
+        className="data zebra"
+      >
+        <thead>
+          <tr>
+            <th>
+              language
+            </th>
+            <th
+              className="thin nowrap"
+            >
+              project_quality_profile.current
+            </th>
+            <th
+              className="thin nowrap text-right"
+            >
+              coding_rules.filters.activation.active_rules
+            </th>
+            <th
+              aria-label="actions"
+            />
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            key="css"
+          >
+            <td>
+              CSS
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                <em>
+                  project_quality_profile.instance_default
+                </em>
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "bar",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+          <tr
+            key="html"
+          >
+            <td>
+              HTML
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                Baz
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "baz",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+          <tr
+            key="js"
+          >
+            <td>
+              JS
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                <em>
+                  project_quality_profile.instance_default
+                </em>
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "foo",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+      <div
+        className="big-spacer-top"
+      >
+        <h2>
+          project_quality_profile.add_language.title
+        </h2>
+        <p
+          className="spacer-top big-spacer-bottom"
+        >
+          project_quality_profile.add_language.description
+        </p>
+        <Button
+          disabled={false}
+          onClick={[MockFunction]}
+        >
+          <PlusCircleIcon
+            className="little-spacer-right"
+          />
+          project_quality_profile.add_language.action
+        </Button>
+      </div>
+      <Connect(AddLanguageModal)
+        onClose={[MockFunction]}
+        onSubmit={[MockFunction]}
+        profilesByLanguage={
+          Object {
+            "css": Array [
+              Object {
+                "activeDeprecatedRuleCount": 2,
+                "activeRuleCount": 10,
+                "childrenCount": 0,
+                "depth": 1,
+                "isBuiltIn": false,
+                "isDefault": false,
+                "isInherited": false,
+                "key": "bar",
+                "language": "css",
+                "languageName": "JavaScript",
+                "name": "name",
+                "organization": "foo",
+                "projectCount": 3,
+              },
+            ],
+            "html": Array [
+              Object {
+                "activeDeprecatedRuleCount": 2,
+                "activeRuleCount": 10,
+                "childrenCount": 0,
+                "depth": 1,
+                "isBuiltIn": false,
+                "isDefault": false,
+                "isInherited": false,
+                "key": "baz",
+                "language": "html",
+                "languageName": "JavaScript",
+                "name": "name",
+                "organization": "foo",
+                "projectCount": 3,
+              },
+            ],
+            "js": Array [
+              Object {
+                "activeDeprecatedRuleCount": 2,
+                "activeRuleCount": 10,
+                "childrenCount": 0,
+                "depth": 1,
+                "isBuiltIn": false,
+                "isDefault": false,
+                "isInherited": false,
+                "key": "foo",
+                "language": "js",
+                "languageName": "JavaScript",
+                "name": "name",
+                "organization": "foo",
+                "projectCount": 3,
+              },
+            ],
+          }
+        }
+        unavailableLanguages={
+          Array [
+            "js",
+            "css",
+            "html",
+          ]
+        }
+      />
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: default 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-profiles"
+>
+  <Suggestions
+    suggestions="project_quality_profiles"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_profiles.page"
+  />
+  <A11ySkipTarget
+    anchor="profiles_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_profiles.page
+         
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_profiles.list.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_profile.subtitle
+    </h2>
+    <div
+      className="boxed-group-inner"
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_profiles.page.description
+      </p>
+      <table
+        className="data zebra"
+      >
+        <thead>
+          <tr>
+            <th>
+              language
+            </th>
+            <th
+              className="thin nowrap"
+            >
+              project_quality_profile.current
+            </th>
+            <th
+              className="thin nowrap text-right"
+            >
+              coding_rules.filters.activation.active_rules
+            </th>
+            <th
+              aria-label="actions"
+            />
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            key="css"
+          >
+            <td>
+              CSS
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                <em>
+                  project_quality_profile.instance_default
+                </em>
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "bar",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+          <tr
+            key="html"
+          >
+            <td>
+              HTML
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                Baz
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "baz",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+          <tr
+            key="js"
+          >
+            <td>
+              JS
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                <em>
+                  project_quality_profile.instance_default
+                </em>
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "foo",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+      <div
+        className="big-spacer-top"
+      >
+        <h2>
+          project_quality_profile.add_language.title
+        </h2>
+        <p
+          className="spacer-top big-spacer-bottom"
+        >
+          project_quality_profile.add_language.description
+        </p>
+        <Button
+          disabled={false}
+          onClick={[MockFunction]}
+        >
+          <PlusCircleIcon
+            className="little-spacer-right"
+          />
+          project_quality_profile.add_language.action
+        </Button>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-profiles"
+>
+  <Suggestions
+    suggestions="project_quality_profiles"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_profiles.page"
+  />
+  <A11ySkipTarget
+    anchor="profiles_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_profiles.page
+         
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_profiles.list.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_profile.subtitle
+    </h2>
+    <div
+      className="boxed-group-inner"
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_profiles.page.description
+      </p>
+      <i
+        className="spinner spacer-left"
+      />
+      <div
+        className="big-spacer-top"
+      >
+        <h2>
+          project_quality_profile.add_language.title
+        </h2>
+        <p
+          className="spacer-top big-spacer-bottom"
+        >
+          project_quality_profile.add_language.description
+        </p>
+        <Button
+          disabled={true}
+          onClick={[MockFunction]}
+        >
+          <PlusCircleIcon
+            className="little-spacer-right"
+          />
+          project_quality_profile.add_language.action
+        </Button>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: open profile 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-profiles"
+>
+  <Suggestions
+    suggestions="project_quality_profiles"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_profiles.page"
+  />
+  <A11ySkipTarget
+    anchor="profiles_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_profiles.page
+         
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_profiles.list.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_profile.subtitle
+    </h2>
+    <div
+      className="boxed-group-inner"
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_profiles.page.description
+      </p>
+      <table
+        className="data zebra"
+      >
+        <thead>
+          <tr>
+            <th>
+              language
+            </th>
+            <th
+              className="thin nowrap"
+            >
+              project_quality_profile.current
+            </th>
+            <th
+              className="thin nowrap text-right"
+            >
+              coding_rules.filters.activation.active_rules
+            </th>
+            <th
+              aria-label="actions"
+            />
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            key="css"
+          >
+            <td>
+              CSS
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                <em>
+                  project_quality_profile.instance_default
+                </em>
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "bar",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+          <tr
+            key="html"
+          >
+            <td>
+              HTML
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                Baz
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "baz",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+          <tr
+            key="js"
+          >
+            <td>
+              JS
+            </td>
+            <td
+              className="thin nowrap"
+            >
+              <span
+                className="display-inline-flex-center"
+              >
+                <em>
+                  project_quality_profile.instance_default
+                </em>
+              </span>
+            </td>
+            <td
+              className="nowrap text-right"
+            >
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to={
+                  Object {
+                    "pathname": "/coding_rules",
+                    "query": Object {
+                      "qprofile": "foo",
+                    },
+                  }
+                }
+              >
+                10
+              </Link>
+            </td>
+            <td
+              className="text-right"
+            >
+              <Button
+                onClick={[Function]}
+              >
+                <EditIcon
+                  className="spacer-right"
+                />
+                project_quality_profile.change_profile
+              </Button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+      <div
+        className="big-spacer-top"
+      >
+        <h2>
+          project_quality_profile.add_language.title
+        </h2>
+        <p
+          className="spacer-top big-spacer-bottom"
+        >
+          project_quality_profile.add_language.description
+        </p>
+        <Button
+          disabled={false}
+          onClick={[MockFunction]}
+        >
+          <PlusCircleIcon
+            className="little-spacer-right"
+          />
+          project_quality_profile.add_language.action
+        </Button>
+      </div>
+      <SetQualityProfileModal
+        availableProfiles={
+          Array [
+            Object {
+              "activeDeprecatedRuleCount": 2,
+              "activeRuleCount": 10,
+              "childrenCount": 0,
+              "depth": 1,
+              "isBuiltIn": false,
+              "isDefault": false,
+              "isInherited": false,
+              "key": "foo",
+              "language": "js",
+              "languageName": "JavaScript",
+              "name": "name",
+              "organization": "foo",
+              "projectCount": 3,
+            },
+          ]
+        }
+        component={
+          Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          }
+        }
+        currentProfile={
+          Object {
+            "activeDeprecatedRuleCount": 2,
+            "activeRuleCount": 10,
+            "childrenCount": 0,
+            "depth": 1,
+            "isBuiltIn": false,
+            "isDefault": false,
+            "isInherited": false,
+            "key": "foo",
+            "language": "js",
+            "languageName": "JavaScript",
+            "name": "name",
+            "organization": "foo",
+            "projectCount": 3,
+          }
+        }
+        onClose={[MockFunction]}
+        onSubmit={[MockFunction]}
+        usesDefault={true}
+      />
+    </div>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Table-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Table-test.tsx.snap
deleted file mode 100644 (file)
index a64ebb3..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders 1`] = `
-<div
-  className="boxed-group boxed-group-inner"
->
-  <table
-    className="data zebra"
-  >
-    <thead>
-      <tr>
-        <th
-          className="thin nowrap"
-        >
-          language
-        </th>
-        <th
-          className="thin nowrap"
-        >
-          quality_profile
-        </th>
-        <th>
-          Â 
-        </th>
-      </tr>
-    </thead>
-    <tbody>
-      <ProfileRow
-        key="java"
-        onChangeProfile={[MockFunction]}
-        possibleProfiles={
-          Array [
-            Object {
-              "activeDeprecatedRuleCount": 0,
-              "activeRuleCount": 17,
-              "key": "foo-java",
-              "language": "java",
-              "languageName": "java",
-              "name": "foo-java",
-              "organization": "org",
-            },
-            Object {
-              "activeDeprecatedRuleCount": 0,
-              "activeRuleCount": 17,
-              "key": "bar-java",
-              "language": "java",
-              "languageName": "java",
-              "name": "bar-java",
-              "organization": "org",
-            },
-            Object {
-              "activeDeprecatedRuleCount": 0,
-              "activeRuleCount": 17,
-              "key": "baz-java",
-              "language": "java",
-              "languageName": "java",
-              "name": "baz-java",
-              "organization": "org",
-            },
-          ]
-        }
-        profile={
-          Object {
-            "activeDeprecatedRuleCount": 0,
-            "activeRuleCount": 17,
-            "key": "foo-java",
-            "language": "java",
-            "languageName": "java",
-            "name": "foo-java",
-            "organization": "org",
-          }
-        }
-      />
-      <ProfileRow
-        key="js"
-        onChangeProfile={[MockFunction]}
-        possibleProfiles={
-          Array [
-            Object {
-              "activeDeprecatedRuleCount": 0,
-              "activeRuleCount": 17,
-              "key": "foo-js",
-              "language": "js",
-              "languageName": "js",
-              "name": "foo-js",
-              "organization": "org",
-            },
-          ]
-        }
-        profile={
-          Object {
-            "activeDeprecatedRuleCount": 0,
-            "activeRuleCount": 17,
-            "key": "foo-js",
-            "language": "js",
-            "languageName": "js",
-            "name": "foo-js",
-            "organization": "org",
-          }
-        }
-      />
-    </tbody>
-  </table>
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx
new file mode 100644 (file)
index 0000000..c8334f5
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { difference } from 'lodash';
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { ButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import Select from 'sonar-ui-common/components/controls/Select';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { Profile } from '../../../api/quality-profiles';
+import { Store } from '../../../store/rootReducer';
+
+export interface AddLanguageModalProps {
+  languages: T.Languages;
+  onClose: () => void;
+  onSubmit: (key: string) => Promise<void>;
+  profilesByLanguage: T.Dict<Profile[]>;
+  unavailableLanguages: string[];
+}
+
+export function AddLanguageModal(props: AddLanguageModalProps) {
+  const { languages, profilesByLanguage, unavailableLanguages } = props;
+
+  const [{ language, key }, setSelected] = React.useState<{ language?: string; key?: string }>({
+    language: undefined,
+    key: undefined
+  });
+
+  const header = translate('project_quality_profile.add_language_modal.title');
+
+  const languageOptions = difference(
+    Object.keys(profilesByLanguage),
+    unavailableLanguages
+  ).map(l => ({ value: l, label: languages[l].name }));
+
+  const profileOptions =
+    language !== undefined
+      ? profilesByLanguage[language].map(p => ({ value: p.key, label: p.name }))
+      : [];
+
+  return (
+    <SimpleModal
+      header={header}
+      onClose={props.onClose}
+      onSubmit={() => {
+        if (language && key) {
+          props.onSubmit(key);
+        }
+      }}>
+      {({ onCloseClick, onFormSubmit, submitting }) => (
+        <>
+          <div className="modal-head">
+            <h2>{header}</h2>
+          </div>
+
+          <form onSubmit={onFormSubmit}>
+            <div className="modal-body">
+              <div className="big-spacer-bottom">
+                <div className="little-spacer-bottom">
+                  <label className="text-bold" htmlFor="language">
+                    {translate('project_quality_profile.add_language_modal.choose_language')}
+                  </label>
+                </div>
+                <Select
+                  className="abs-width-300"
+                  clearable={false}
+                  disabled={submitting}
+                  id="language"
+                  onChange={({ value }: { value: string }) => setSelected({ language: value })}
+                  options={languageOptions}
+                  value={language}
+                />
+              </div>
+
+              <div className="big-spacer-bottom">
+                <div className="little-spacer-bottom">
+                  <label className="text-bold" htmlFor="profiles">
+                    {translate('project_quality_profile.add_language_modal.choose_profile')}
+                  </label>
+                </div>
+                <Select
+                  className="abs-width-300"
+                  clearable={false}
+                  disabled={submitting || !language}
+                  id="profiles"
+                  onChange={({ value }: { value: string }) => setSelected({ language, key: value })}
+                  options={profileOptions}
+                  value={key}
+                />
+              </div>
+            </div>
+
+            <div className="modal-foot">
+              {submitting && <i className="spinner spacer-right" />}
+              <SubmitButton disabled={submitting || !language || !key}>
+                {translate('save')}
+              </SubmitButton>
+              <ButtonLink disabled={submitting} onClick={onCloseClick}>
+                {translate('cancel')}
+              </ButtonLink>
+            </div>
+          </form>
+        </>
+      )}
+    </SimpleModal>
+  );
+}
+
+function mapStateToProps({ languages }: Store) {
+  return { languages };
+}
+
+export default connect(mapStateToProps)(AddLanguageModal);
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx
new file mode 100644 (file)
index 0000000..ebd8def
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { ButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import Radio from 'sonar-ui-common/components/controls/Radio';
+import Select from 'sonar-ui-common/components/controls/Select';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { Profile } from '../../../api/quality-profiles';
+import BuiltInQualityProfileBadge from '../../quality-profiles/components/BuiltInQualityProfileBadge';
+import { USE_SYSTEM_DEFAULT } from '../constants';
+
+export interface SetQualityProfileModalProps {
+  availableProfiles: Profile[];
+  component: T.Component;
+  currentProfile: Profile;
+  onClose: () => void;
+  onSubmit: (newKey: string | undefined, oldKey: string) => Promise<void>;
+  usesDefault: boolean;
+}
+
+export default function SetQualityProfileModal(props: SetQualityProfileModalProps) {
+  const { availableProfiles, component, currentProfile, usesDefault } = props;
+  const [selected, setSelected] = React.useState(
+    usesDefault ? USE_SYSTEM_DEFAULT : currentProfile.key
+  );
+
+  const defaultProfile = availableProfiles.find(p => p.isDefault);
+
+  if (defaultProfile === undefined) {
+    // Cannot be undefined
+    return null;
+  }
+
+  const header = translateWithParameters(
+    'project_quality_profile.change_lang_X_profile',
+    currentProfile.languageName
+  );
+  const profileOptions = availableProfiles.map(p => ({ value: p.key, label: p.name }));
+  const hasSelectedSysDefault = selected === USE_SYSTEM_DEFAULT;
+  const hasChanged = usesDefault ? !hasSelectedSysDefault : selected !== currentProfile.key;
+  const needsReanalysis = !component.qualityProfiles?.some(p =>
+    hasSelectedSysDefault ? p.key === defaultProfile.key : p.key === selected
+  );
+
+  return (
+    <SimpleModal
+      header={header}
+      onClose={props.onClose}
+      onSubmit={() =>
+        props.onSubmit(hasSelectedSysDefault ? undefined : selected, currentProfile.key)
+      }>
+      {({ onCloseClick, onFormSubmit, submitting }) => (
+        <>
+          <div className="modal-head">
+            <h2>{header}</h2>
+          </div>
+
+          <form onSubmit={onFormSubmit}>
+            <div className="modal-body">
+              <div className="big-spacer-bottom">
+                <Radio
+                  className="display-flex-start"
+                  checked={hasSelectedSysDefault}
+                  disabled={submitting}
+                  onCheck={() => setSelected(USE_SYSTEM_DEFAULT)}
+                  value={USE_SYSTEM_DEFAULT}>
+                  <div className="spacer-left">
+                    <div className="little-spacer-bottom">
+                      {translate('project_quality_profile.always_use_default')}
+                    </div>
+                    <div className="display-flex-center">
+                      <span className="text-muted spacer-right">{translate('current_noun')}:</span>
+                      {defaultProfile.name}
+                      {defaultProfile.isBuiltIn && (
+                        <BuiltInQualityProfileBadge className="spacer-left" />
+                      )}
+                    </div>
+                  </div>
+                </Radio>
+              </div>
+
+              <div className="big-spacer-bottom">
+                <Radio
+                  className="display-flex-start"
+                  checked={!hasSelectedSysDefault}
+                  disabled={submitting}
+                  onCheck={() =>
+                    setSelected(!hasSelectedSysDefault ? selected : currentProfile.key)
+                  }
+                  value={currentProfile.key}>
+                  <div className="spacer-left">
+                    <div className="little-spacer-bottom">
+                      {translate('project_quality_profile.always_use_specific')}
+                    </div>
+                    <div className="display-flex-center">
+                      <Select
+                        className="abs-width-300"
+                        clearable={false}
+                        disabled={submitting || hasSelectedSysDefault}
+                        onChange={({ value }: { value: string }) => setSelected(value)}
+                        options={profileOptions}
+                        optionRenderer={option => <span>{option.label}</span>}
+                        value={!hasSelectedSysDefault ? selected : currentProfile.key}
+                      />
+                    </div>
+                  </div>
+                </Radio>
+              </div>
+
+              {needsReanalysis && (
+                <Alert variant="warning">
+                  {translate('project_quality_profile.requires_new_analysis')}
+                </Alert>
+              )}
+            </div>
+
+            <div className="modal-foot">
+              {submitting && <i className="spinner spacer-right" />}
+              <SubmitButton disabled={submitting || !hasChanged}>{translate('save')}</SubmitButton>
+              <ButtonLink disabled={submitting} onClick={onCloseClick}>
+                {translate('cancel')}
+              </ButtonLink>
+            </div>
+          </form>
+        </>
+      )}
+    </SimpleModal>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx
new file mode 100644 (file)
index 0000000..3ae111d
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow, ShallowWrapper } from 'enzyme';
+import * as React from 'react';
+import Select from 'sonar-ui-common/components/controls/Select';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
+import { mockQualityProfile } from '../../../../helpers/testMocks';
+import { AddLanguageModal, AddLanguageModalProps } from '../AddLanguageModal';
+
+it('should render correctly', () => {
+  expect(diveIntoSimpleModal(shallowRender())).toMatchSnapshot('default');
+});
+
+it('should correctly handle changes', () => {
+  return new Promise(resolve => {
+    const onSubmit = jest.fn();
+    const wrapper = shallowRender({ onSubmit });
+
+    const langSelect = getLanguageSelect(wrapper);
+    let profileSelect = getProfileSelect(wrapper);
+
+    // Language select should only have 2; JS is not available. Profile Select
+    // should have none, as no language is selected yet.
+    expect(langSelect.props().options).toHaveLength(2);
+    expect(profileSelect.props().options).toHaveLength(0);
+
+    // Choose CSS.
+    const langChange = langSelect.props().onChange;
+    if (langChange) {
+      langChange({ value: 'css' });
+
+      // Should now show 2 available profiles.
+      profileSelect = getProfileSelect(wrapper);
+      expect(profileSelect.props().options).toHaveLength(2);
+
+      // Choose 1 profile.
+      const profileChange = profileSelect.props().onChange;
+      if (profileChange) {
+        profileChange({ value: 'css2' });
+
+        submitSimpleModal(wrapper);
+        expect(onSubmit).toHaveBeenLastCalledWith('css2');
+
+        resolve();
+      }
+    }
+  });
+});
+
+function diveIntoSimpleModal(wrapper: ShallowWrapper) {
+  return wrapper
+    .find(SimpleModal)
+    .dive()
+    .children();
+}
+
+function getLanguageSelect(wrapper: ShallowWrapper) {
+  return diveIntoSimpleModal(wrapper)
+    .find(Select)
+    .at(0);
+}
+
+function getProfileSelect(wrapper: ShallowWrapper) {
+  return diveIntoSimpleModal(wrapper)
+    .find(Select)
+    .at(1);
+}
+
+function submitSimpleModal(wrapper: ShallowWrapper) {
+  wrapper
+    .find(SimpleModal)
+    .props()
+    .onSubmit();
+}
+
+function shallowRender(props: Partial<AddLanguageModalProps> = {}) {
+  return shallow<AddLanguageModalProps>(
+    <AddLanguageModal
+      languages={{
+        css: { key: 'css', name: 'CSS' },
+        ts: { key: 'ts', name: 'TS' },
+        js: { key: 'js', name: 'JS' }
+      }}
+      onClose={jest.fn()}
+      onSubmit={jest.fn()}
+      profilesByLanguage={{
+        css: [
+          mockQualityProfile({ key: 'css', name: 'CSS' }),
+          mockQualityProfile({ key: 'css2', name: 'CSS 2' })
+        ],
+        ts: [mockQualityProfile({ key: 'ts', name: 'TS' })],
+        js: [mockQualityProfile({ key: 'js', name: 'JS' })]
+      }}
+      unavailableLanguages={['js']}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx
new file mode 100644 (file)
index 0000000..de71652
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow, ShallowWrapper } from 'enzyme';
+import * as React from 'react';
+import Radio from 'sonar-ui-common/components/controls/Radio';
+import Select from 'sonar-ui-common/components/controls/Select';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
+import { mockComponent, mockQualityProfile } from '../../../../helpers/testMocks';
+import SetQualityProfileModal, { SetQualityProfileModalProps } from '../SetQualityProfileModal';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ usesDefault: true })).toMatchSnapshot('inherits system default');
+  expect(shallowRender({ component: mockComponent() })).toMatchSnapshot('needs reanalysis');
+});
+
+it('should render select options correctly', () => {
+  return new Promise(resolve => {
+    const wrapper = shallowRender();
+    const render = wrapper.find(Select).props().optionRenderer;
+    if (render) {
+      expect(render({ value: 'bar', label: 'Profile 1' })).toMatchSnapshot('default');
+      resolve();
+    }
+  });
+});
+
+it('should correctly handle changes', () => {
+  return new Promise(resolve => {
+    const onSubmit = jest.fn();
+    const wrapper = shallowRender({ onSubmit }, false);
+
+    diveIntoSimpleModal(wrapper)
+      .find(Radio)
+      .at(0)
+      .props()
+      .onCheck('');
+    submitSimpleModal(wrapper);
+    expect(onSubmit).toHaveBeenLastCalledWith(undefined, 'foo');
+
+    diveIntoSimpleModal(wrapper)
+      .find(Radio)
+      .at(1)
+      .props()
+      .onCheck('');
+    submitSimpleModal(wrapper);
+    expect(onSubmit).toHaveBeenLastCalledWith('foo', 'foo');
+
+    const change = diveIntoSimpleModal(wrapper)
+      .find(Select)
+      .props().onChange;
+    if (change) {
+      change({ value: 'bar' });
+      submitSimpleModal(wrapper);
+      expect(onSubmit).toHaveBeenLastCalledWith('bar', 'foo');
+
+      resolve();
+    }
+  });
+});
+
+function diveIntoSimpleModal(wrapper: ShallowWrapper) {
+  return wrapper
+    .find(SimpleModal)
+    .dive()
+    .children();
+}
+
+function submitSimpleModal(wrapper: ShallowWrapper) {
+  wrapper
+    .find(SimpleModal)
+    .props()
+    .onSubmit();
+}
+
+function shallowRender(props: Partial<SetQualityProfileModalProps> = {}, dive = true) {
+  const wrapper = shallow<SetQualityProfileModalProps>(
+    <SetQualityProfileModal
+      availableProfiles={[
+        mockQualityProfile({ key: 'foo', isDefault: true, language: 'js' }),
+        mockQualityProfile({ key: 'bar', language: 'js' })
+      ]}
+      component={mockComponent({ qualityProfiles: [{ key: 'foo', name: 'Foo', language: 'js' }] })}
+      currentProfile={mockQualityProfile({ key: 'foo', language: 'js' })}
+      onClose={jest.fn()}
+      onSubmit={jest.fn()}
+      usesDefault={false}
+      {...props}
+    />
+  );
+
+  return dive ? diveIntoSimpleModal(wrapper) : wrapper;
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap
new file mode 100644 (file)
index 0000000..96957df
--- /dev/null
@@ -0,0 +1,91 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+Array [
+  <div
+    className="modal-head"
+  >
+    <h2>
+      project_quality_profile.add_language_modal.title
+    </h2>
+  </div>,
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="big-spacer-bottom"
+      >
+        <div
+          className="little-spacer-bottom"
+        >
+          <label
+            className="text-bold"
+            htmlFor="language"
+          >
+            project_quality_profile.add_language_modal.choose_language
+          </label>
+        </div>
+        <Select
+          className="abs-width-300"
+          clearable={false}
+          disabled={false}
+          id="language"
+          onChange={[Function]}
+          options={
+            Array [
+              Object {
+                "label": "CSS",
+                "value": "css",
+              },
+              Object {
+                "label": "TS",
+                "value": "ts",
+              },
+            ]
+          }
+        />
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <div
+          className="little-spacer-bottom"
+        >
+          <label
+            className="text-bold"
+            htmlFor="profiles"
+          >
+            project_quality_profile.add_language_modal.choose_profile
+          </label>
+        </div>
+        <Select
+          className="abs-width-300"
+          clearable={false}
+          disabled={true}
+          id="profiles"
+          onChange={[Function]}
+          options={Array []}
+        />
+      </div>
+    </div>
+    <div
+      className="modal-foot"
+    >
+      <SubmitButton
+        disabled={true}
+      >
+        save
+      </SubmitButton>
+      <ButtonLink
+        disabled={false}
+        onClick={[Function]}
+      >
+        cancel
+      </ButtonLink>
+    </div>
+  </form>,
+]
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap
new file mode 100644 (file)
index 0000000..d14dd48
--- /dev/null
@@ -0,0 +1,348 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+Array [
+  <div
+    className="modal-head"
+  >
+    <h2>
+      project_quality_profile.change_lang_X_profile.JavaScript
+    </h2>
+  </div>,
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={false}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="-1"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_profile.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              name
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={true}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="foo"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_profile.always_use_specific
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <Select
+                className="abs-width-300"
+                clearable={false}
+                disabled={false}
+                onChange={[Function]}
+                optionRenderer={[Function]}
+                options={
+                  Array [
+                    Object {
+                      "label": "name",
+                      "value": "foo",
+                    },
+                    Object {
+                      "label": "name",
+                      "value": "bar",
+                    },
+                  ]
+                }
+                value="foo"
+              />
+            </div>
+          </div>
+        </Radio>
+      </div>
+    </div>
+    <div
+      className="modal-foot"
+    >
+      <SubmitButton
+        disabled={true}
+      >
+        save
+      </SubmitButton>
+      <ButtonLink
+        disabled={false}
+        onClick={[Function]}
+      >
+        cancel
+      </ButtonLink>
+    </div>
+  </form>,
+]
+`;
+
+exports[`should render correctly: inherits system default 1`] = `
+Array [
+  <div
+    className="modal-head"
+  >
+    <h2>
+      project_quality_profile.change_lang_X_profile.JavaScript
+    </h2>
+  </div>,
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={true}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="-1"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_profile.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              name
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={false}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="foo"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_profile.always_use_specific
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <Select
+                className="abs-width-300"
+                clearable={false}
+                disabled={true}
+                onChange={[Function]}
+                optionRenderer={[Function]}
+                options={
+                  Array [
+                    Object {
+                      "label": "name",
+                      "value": "foo",
+                    },
+                    Object {
+                      "label": "name",
+                      "value": "bar",
+                    },
+                  ]
+                }
+                value="foo"
+              />
+            </div>
+          </div>
+        </Radio>
+      </div>
+    </div>
+    <div
+      className="modal-foot"
+    >
+      <SubmitButton
+        disabled={true}
+      >
+        save
+      </SubmitButton>
+      <ButtonLink
+        disabled={false}
+        onClick={[Function]}
+      >
+        cancel
+      </ButtonLink>
+    </div>
+  </form>,
+]
+`;
+
+exports[`should render correctly: needs reanalysis 1`] = `
+Array [
+  <div
+    className="modal-head"
+  >
+    <h2>
+      project_quality_profile.change_lang_X_profile.JavaScript
+    </h2>
+  </div>,
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={false}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="-1"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_profile.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              name
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={true}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="foo"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_profile.always_use_specific
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <Select
+                className="abs-width-300"
+                clearable={false}
+                disabled={false}
+                onChange={[Function]}
+                optionRenderer={[Function]}
+                options={
+                  Array [
+                    Object {
+                      "label": "name",
+                      "value": "foo",
+                    },
+                    Object {
+                      "label": "name",
+                      "value": "bar",
+                    },
+                  ]
+                }
+                value="foo"
+              />
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <Alert
+        variant="warning"
+      >
+        project_quality_profile.requires_new_analysis
+      </Alert>
+    </div>
+    <div
+      className="modal-foot"
+    >
+      <SubmitButton
+        disabled={true}
+      >
+        save
+      </SubmitButton>
+      <ButtonLink
+        disabled={false}
+        onClick={[Function]}
+      >
+        cancel
+      </ButtonLink>
+    </div>
+  </form>,
+]
+`;
+
+exports[`should render select options correctly: default 1`] = `
+<span>
+  Profile 1
+</span>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/constants.ts b/server/sonar-web/src/main/js/apps/projectQualityProfiles/constants.ts
new file mode 100644 (file)
index 0000000..64d7d66
--- /dev/null
@@ -0,0 +1,20 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.
+ */
+export const USE_SYSTEM_DEFAULT = '-1';
index d82abd3b1461caa1b583ed5901e36d1eaf991d3e..8ede0cfc4ad6d9dbd83679f225d732563d89a162 100644 (file)
@@ -21,7 +21,9 @@ import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent'
 
 const routes = [
   {
-    indexRoute: { component: lazyLoadComponent(() => import('./App')) }
+    indexRoute: {
+      component: lazyLoadComponent(() => import('./ProjectQualityProfilesApp'))
+    }
   }
 ];
 
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/types.ts b/server/sonar-web/src/main/js/apps/projectQualityProfiles/types.ts
new file mode 100644 (file)
index 0000000..0024277
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { Profile } from '../../api/quality-profiles';
+
+export interface ProjectProfile {
+  profile: Profile;
+  selected: boolean;
+}
index bd49bac6d31640fcb684f0fcadcad2d714a924b5..663fc305c5c12055fbcdfc83fd20a96bca9ebf22 100644 (file)
@@ -540,7 +540,7 @@ settings.page=General Settings
 settings.page.description=Edit global settings for this {instance} instance.
 system_info.page=System Info
 project_quality_profiles.page=Quality Profiles
-project_quality_profiles.page.description=Choose which profile is associated with this project on a language-by-language basis. (Note that you will only need to select profiles for multiple languages for multi-language projects.)
+project_quality_profiles.page.description=Choose which profile is associated with this project on a language-by-language basis.
 project_quality_gate.page=Quality Gate
 project_quality_gate.page.description=Choose which quality gate is associated with this project.
 update_key.page=Update Key
@@ -1350,8 +1350,21 @@ update_key.are_you_sure_to_change_key=Are you sure you want to change key of "{0
 # PROJECT QUALITY PROFILE PAGE
 #
 #------------------------------------------------------------------------------
-project_quality_profile.default_profile=Default
+project_quality_profile.instance_default=Instance default
 project_quality_profile.successfully_updated={0} Quality Profile has been successfully updated.
+project_quality_profile.subtitle=Manage project Quality Profiles
+project_quality_profile.always_use_default=Always use the instance default Quality Profile
+project_quality_profile.current=Current Quality Profile
+project_quality_profile.always_use_specific=Always use a specific Quality Profile
+project_quality_profile.change_lang_X_profile=Change {0} Quality Profile
+project_quality_profile.requires_new_analysis=Changes will be applied after the next analysis.
+project_quality_profile.add_language.title=Add a new language
+project_quality_profile.add_language.description=Manually configure a specific profile for a new language before the next analysis.
+project_quality_profile.add_language.action=Add language
+project_quality_profile.add_language_modal.title=Add a language
+project_quality_profile.add_language_modal.choose_language=Choose a language
+project_quality_profile.add_language_modal.choose_profile=Choose a profile
+project_quality_profile.change_profile=Change profile
 
 #------------------------------------------------------------------------------
 #
@@ -1444,7 +1457,7 @@ quality_profiles.activate_more_rules=Activate More Rules
 quality_profiles.intro1=Quality Profiles are collections of rules to apply during an analysis.
 quality_profiles.intro2=For each language there is a default profile. All projects not explicitly assigned to some other profile will be analyzed with the default. Ideally, all projects will use the same profile for a language.
 quality_profiles.list.projects=Projects
-quality_profiles.list.projects.help=Projects assigned to a profile will always be analyzed with it for that language, regardless of which profile is the default. Quality Profile administrators may assign projects to a profile. Project administrators may also choose a non-default profile for each language.
+quality_profiles.list.projects.help=Projects assigned to a profile will always be analyzed with it for that language, regardless of which profile is the default. Quality Profile administrators may assign projects to a non-default profile, or always make it follow the system default. Project administrators may choose any profile for each language.
 quality_profiles.list.rules=Rules
 quality_profiles.list.updated=Updated
 quality_profiles.list.used=Used
@@ -1631,6 +1644,7 @@ coding_rules.filter_similar_rules=Filter Similar Rules
 
 coding_rules.filters.activation=Activation
 coding_rules.filters.activation.active=Active
+coding_rules.filters.activation.active_rules=Active Rules
 coding_rules.filters.activation.inactive=Inactive
 coding_rules.filters.activation.help=Activation criterion is available when a Quality Profile is selected
 coding_rules.filters.active_severity=Active Severity