]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-13856 Add 'Always use the Default' option at project level for QG
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Tue, 8 Sep 2020 14:29:17 +0000 (16:29 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 8 Oct 2020 20:08:02 +0000 (20:08 +0000)
19 files changed:
server/sonar-web/src/main/js/api/quality-gates.ts
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/apps/projectQualityGate/App.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/Form.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/Header.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/App-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Form-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Header-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Form-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Header-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateApp-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/constants.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectQualityGate/routes.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 4078b2664d7a48399ba34c39e41dc05c4856101c..054e1b63708354472e6936e783923974cf2d74f5 100644 (file)
@@ -22,7 +22,7 @@ import throwGlobalError from '../app/utils/throwGlobalError';
 import { BranchParameters } from '../types/branch-like';
 import { QualityGateApplicationStatus, QualityGateProjectStatus } from '../types/quality-gates';
 
-export function fetchQualityGates(data: {
+export function fetchQualityGates(data?: {
   organization?: string;
 }): Promise<{
   actions: { create: boolean };
index 7b277f0c01884103357052817a57a70830ca8fcc..4c6ef70ab93b754b322ee24bd5b83a325742b7c5 100644 (file)
@@ -434,7 +434,7 @@ th.huge-spacer-right {
 
 .display-flex-start {
   display: flex !important;
-  align-items: flex-start;
+  align-items: flex-start !important;
 }
 
 .display-flex-end {
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/App.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/App.tsx
deleted file mode 100644 (file)
index 904280f..0000000
+++ /dev/null
@@ -1,147 +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 } from 'sonar-ui-common/helpers/l10n';
-import {
-  associateGateWithProject,
-  dissociateGateWithProject,
-  fetchQualityGates,
-  getGateForProject
-} from '../../api/quality-gates';
-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 Form from './Form';
-import Header from './Header';
-
-interface Props {
-  component: T.Component;
-  onComponentChange: (changes: {}) => void;
-}
-
-interface State {
-  allGates?: T.QualityGate[];
-  gate?: T.QualityGate;
-  loading: boolean;
-}
-
-export default class App extends React.PureComponent<Props> {
-  mounted = false;
-  state: State = { loading: true };
-
-  componentDidMount() {
-    this.mounted = true;
-    if (this.checkPermissions()) {
-      this.fetchQualityGates();
-    } else {
-      handleRequiredAuthorization();
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  checkPermissions() {
-    const { configuration } = this.props.component;
-    const hasPermission = configuration && configuration.showQualityGates;
-    return !!hasPermission;
-  }
-
-  fetchQualityGates() {
-    const { component } = this.props;
-    this.setState({ loading: true });
-    Promise.all([
-      fetchQualityGates({ organization: component.organization }),
-      getGateForProject({ organization: component.organization, project: component.key })
-    ]).then(
-      ([qualityGateList, gate]) => {
-        if (this.mounted) {
-          this.setState({
-            allGates: qualityGateList?.qualitygates,
-            gate,
-            loading: false
-          });
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      }
-    );
-  }
-
-  handleChangeGate = (oldId?: string, newId?: string) => {
-    const { allGates } = this.state;
-    if ((!oldId && !newId) || !allGates) {
-      return Promise.resolve();
-    }
-
-    const { component } = this.props;
-    const requestData = {
-      gateId: newId ? newId : oldId!,
-      organization: component.organization,
-      projectKey: component.key
-    };
-    const request = newId
-      ? associateGateWithProject(requestData)
-      : dissociateGateWithProject(requestData);
-
-    return request.then(() => {
-      if (this.mounted) {
-        addGlobalSuccessMessage(translate('project_quality_gate.successfully_updated'));
-        if (newId) {
-          const newGate = allGates.find(gate => gate.id === newId);
-          if (newGate) {
-            this.setState({ gate: newGate });
-            this.props.onComponentChange({ qualityGate: newGate });
-          }
-        } else {
-          this.setState({ gate: undefined });
-        }
-      }
-    });
-  };
-
-  render() {
-    if (!this.checkPermissions()) {
-      return null;
-    }
-
-    const { allGates, gate, loading } = this.state;
-
-    return (
-      <div className="page page-limited" id="project-quality-gate">
-        <Suggestions suggestions="project_quality_gate" />
-        <Helmet defer={false} title={translate('project_quality_gate.page')} />
-        <A11ySkipTarget anchor="qg_main" />
-        <Header />
-        {loading ? (
-          <i className="spinner" />
-        ) : (
-          allGates && <Form allGates={allGates} gate={gate} onChange={this.handleChangeGate} />
-        )}
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/Form.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/Form.tsx
deleted file mode 100644 (file)
index bdd3eb3..0000000
+++ /dev/null
@@ -1,117 +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';
-
-interface Props {
-  allGates: T.QualityGate[];
-  gate?: T.QualityGate;
-  onChange: (oldGate?: string, newGate?: string) => Promise<void>;
-}
-
-interface State {
-  loading: boolean;
-}
-
-interface Option {
-  isDefault?: boolean;
-  label: string;
-  value: string;
-}
-
-export default class Form 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 }) => {
-    const { gate } = this.props;
-
-    const isSet = gate == null && option.value != null;
-    const isUnset = gate != null && option.value == null;
-    const isChanged = gate != null && gate.id !== option.value;
-    const hasChanged = isSet || isUnset || isChanged;
-
-    if (hasChanged) {
-      this.setState({ loading: true });
-      this.props.onChange(gate && gate.id, option.value).then(this.stopLoading, this.stopLoading);
-    }
-  };
-
-  renderGateName = (option: { isDefault?: boolean; label: string }) => {
-    if (option.isDefault) {
-      return (
-        <span>
-          <strong>{translate('default')}</strong>
-          {': '}
-          {option.label}
-        </span>
-      );
-    }
-
-    return <span>{option.label}</span>;
-  };
-
-  renderSelect() {
-    const { gate, allGates } = this.props;
-
-    const options: Option[] = allGates.map(gate => ({
-      value: String(gate.id),
-      label: gate.name,
-      isDefault: gate.isDefault
-    }));
-
-    return (
-      <Select
-        clearable={false}
-        disabled={this.state.loading}
-        onChange={this.handleChange}
-        optionRenderer={this.renderGateName}
-        options={options}
-        style={{ width: 300 }}
-        value={gate && String(gate.id)}
-        valueRenderer={this.renderGateName}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        {this.renderSelect()}
-        {this.state.loading && <i className="spinner spacer-left" />}
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/Header.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/Header.tsx
deleted file mode 100644 (file)
index d2fee01..0000000
+++ /dev/null
@@ -1,41 +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_gate.page')}</h1>
-        <HelpTooltip
-          className="spacer-left"
-          overlay={
-            <div className="big-padded-top big-padded-bottom">
-              {translate('quality_gates.projects.help')}
-            </div>
-          }
-        />
-      </div>
-      <div className="page-description">{translate('project_quality_gate.page.description')}</div>
-    </header>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx
new file mode 100644 (file)
index 0000000..98ec647
--- /dev/null
@@ -0,0 +1,192 @@
+/*
+ * 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 { translate } from 'sonar-ui-common/helpers/l10n';
+import {
+  associateGateWithProject,
+  dissociateGateWithProject,
+  fetchQualityGates,
+  getGateForProject,
+  searchProjects
+} from '../../api/quality-gates';
+import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage';
+import handleRequiredAuthorization from '../../app/utils/handleRequiredAuthorization';
+import { USE_SYSTEM_DEFAULT } from './constants';
+import ProjectQualityGateAppRenderer from './ProjectQualityGateAppRenderer';
+
+interface Props {
+  component: T.Component;
+  onComponentChange: (changes: {}) => void;
+}
+
+interface State {
+  allQualityGates?: T.QualityGate[];
+  currentQualityGate?: T.QualityGate;
+  loading: boolean;
+  selectedQualityGateId: string;
+  submitting: boolean;
+}
+
+export default class ProjectQualityGateApp extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {
+    loading: true,
+    selectedQualityGateId: USE_SYSTEM_DEFAULT,
+    submitting: false
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    if (this.checkPermissions()) {
+      this.fetchQualityGates();
+    } else {
+      handleRequiredAuthorization();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  checkPermissions = () => {
+    const { configuration } = this.props.component;
+    const hasPermission = configuration && configuration.showQualityGates;
+    return !!hasPermission;
+  };
+
+  isUsingDefault = async (qualityGate: T.QualityGate) => {
+    const { component } = this.props;
+
+    if (!qualityGate.isDefault) {
+      return false;
+    } else {
+      // If this is the default Quality Gate, check if it was explicitly
+      // selected, or if we're inheriting the system default.
+      /* eslint-disable-next-line sonarjs/prefer-immediate-return */
+      const selected = await searchProjects({
+        gateName: qualityGate.name,
+        query: component.key
+      })
+        .then(({ results }) => {
+          return Boolean(results.find(r => r.key === component.key)?.selected);
+        })
+        .catch(() => false);
+
+      // If it's NOT selected, it means we're following the system default.
+      return !selected;
+    }
+  };
+
+  fetchQualityGates = async () => {
+    const { component } = this.props;
+    this.setState({ loading: true });
+
+    const [allQualityGates, currentQualityGate] = await Promise.all([
+      fetchQualityGates().then(({ qualitygates }) => qualitygates),
+      getGateForProject({ project: component.key })
+    ]).catch(() => []);
+
+    if (allQualityGates && currentQualityGate) {
+      const usingDefault = await this.isUsingDefault(currentQualityGate);
+
+      if (this.mounted) {
+        this.setState({
+          allQualityGates,
+          currentQualityGate,
+          selectedQualityGateId: usingDefault ? USE_SYSTEM_DEFAULT : currentQualityGate.id,
+          loading: false
+        });
+      }
+    } else if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  handleSelect = (selectedQualityGateId: string) => {
+    this.setState({ selectedQualityGateId });
+  };
+
+  handleSubmit = async () => {
+    const { component } = this.props;
+    const { allQualityGates, currentQualityGate, selectedQualityGateId } = this.state;
+
+    if (allQualityGates === undefined || currentQualityGate === undefined) {
+      return;
+    }
+
+    this.setState({ submitting: true });
+
+    if (selectedQualityGateId === USE_SYSTEM_DEFAULT) {
+      await dissociateGateWithProject({
+        gateId: currentQualityGate.id,
+        projectKey: component.key
+      }).catch(() => {
+        /* noop */
+      });
+    } else {
+      await associateGateWithProject({
+        gateId: selectedQualityGateId,
+        projectKey: component.key
+      }).catch(() => {
+        /* noop */
+      });
+    }
+
+    if (this.mounted) {
+      addGlobalSuccessMessage(translate('project_quality_gate.successfully_updated'));
+
+      const newGate =
+        selectedQualityGateId === USE_SYSTEM_DEFAULT
+          ? allQualityGates.find(gate => gate.isDefault)
+          : allQualityGates.find(gate => gate.id === selectedQualityGateId);
+
+      if (newGate) {
+        this.setState({ currentQualityGate: newGate, submitting: false });
+        this.props.onComponentChange({ qualityGate: newGate });
+      }
+    }
+  };
+
+  render() {
+    if (!this.checkPermissions()) {
+      return null;
+    }
+
+    const {
+      allQualityGates,
+      currentQualityGate,
+      loading,
+      selectedQualityGateId,
+      submitting
+    } = this.state;
+
+    return (
+      <ProjectQualityGateAppRenderer
+        allQualityGates={allQualityGates}
+        currentQualityGate={currentQualityGate}
+        loading={loading}
+        onSubmit={this.handleSubmit}
+        onSelect={this.handleSelect}
+        selectedQualityGateId={selectedQualityGateId}
+        submitting={submitting}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx
new file mode 100644 (file)
index 0000000..e8baa53
--- /dev/null
@@ -0,0 +1,166 @@
+/*
+ * 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 { SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip';
+import Radio from 'sonar-ui-common/components/controls/Radio';
+import Select from 'sonar-ui-common/components/controls/Select';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import A11ySkipTarget from '../../app/components/a11y/A11ySkipTarget';
+import Suggestions from '../../app/components/embed-docs-modal/Suggestions';
+import BuiltInQualityGateBadge from '../quality-gates/components/BuiltInQualityGateBadge';
+import { USE_SYSTEM_DEFAULT } from './constants';
+
+export interface ProjectQualityGateAppRendererProps {
+  allQualityGates?: T.QualityGate[];
+  currentQualityGate?: T.QualityGate;
+  loading: boolean;
+  onSelect: (id: string) => void;
+  onSubmit: () => void;
+  selectedQualityGateId: string;
+  submitting: boolean;
+}
+
+export default function ProjectQualityGateAppRenderer(props: ProjectQualityGateAppRendererProps) {
+  const { allQualityGates, currentQualityGate, loading, selectedQualityGateId, submitting } = props;
+  const defaultQualityGate = allQualityGates?.find(g => g.isDefault);
+
+  if (loading) {
+    return <i className="spinner" />;
+  }
+
+  if (
+    allQualityGates === undefined ||
+    defaultQualityGate === undefined ||
+    currentQualityGate === undefined
+  ) {
+    return null;
+  }
+
+  const usesDefault = selectedQualityGateId === USE_SYSTEM_DEFAULT;
+  const needsReanalysis = usesDefault
+    ? // currentQualityGate.isDefault is not always up to date. We need to check
+      // against defaultQualityGate explicitly.
+      defaultQualityGate.id !== currentQualityGate.id
+    : selectedQualityGateId !== currentQualityGate.id;
+
+  const options = allQualityGates.map(g => ({
+    label: g.name,
+    value: g.id
+  }));
+
+  return (
+    <div className="page page-limited" id="project-quality-gate">
+      <Suggestions suggestions="project_quality_gate" />
+      <Helmet defer={false} title={translate('project_quality_gate.page')} />
+      <A11ySkipTarget anchor="qg_main" />
+
+      <header className="page-header">
+        <div className="page-title display-flex-center">
+          <h1>{translate('project_quality_gate.page')}</h1>
+          <HelpTooltip
+            className="spacer-left"
+            overlay={
+              <div className="big-padded-top big-padded-bottom">
+                {translate('quality_gates.projects.help')}
+              </div>
+            }
+          />
+        </div>
+      </header>
+
+      <div className="boxed-group">
+        <h2 className="boxed-group-header">{translate('project_quality_gate.subtitle')}</h2>
+
+        <form
+          className="boxed-group-inner"
+          onSubmit={e => {
+            e.preventDefault();
+            props.onSubmit();
+          }}>
+          <p className="big-spacer-bottom">{translate('project_quality_gate.page.description')}</p>
+
+          <div className="big-spacer-bottom">
+            <Radio
+              className="display-flex-start"
+              checked={usesDefault}
+              disabled={submitting}
+              onCheck={() => props.onSelect(USE_SYSTEM_DEFAULT)}
+              value={USE_SYSTEM_DEFAULT}>
+              <div className="spacer-left">
+                <div className="little-spacer-bottom">
+                  {translate('project_quality_gate.always_use_default')}
+                </div>
+                <div className="display-flex-center">
+                  <span className="text-muted little-spacer-right">
+                    {translate('current_noun')}:
+                  </span>
+                  {defaultQualityGate.name}
+                  {defaultQualityGate.isBuiltIn && (
+                    <BuiltInQualityGateBadge className="spacer-left" />
+                  )}
+                </div>
+              </div>
+            </Radio>
+          </div>
+
+          <div className="big-spacer-bottom">
+            <Radio
+              className="display-flex-start"
+              checked={!usesDefault}
+              disabled={submitting}
+              onCheck={value => props.onSelect(value)}
+              value={!usesDefault ? selectedQualityGateId : currentQualityGate.id}>
+              <div className="spacer-left">
+                <div className="little-spacer-bottom">
+                  {translate('project_quality_gate.always_use_specific')}
+                </div>
+                <div className="display-flex-center">
+                  <Select
+                    className="abs-width-300"
+                    clearable={false}
+                    disabled={submitting || usesDefault}
+                    onChange={({ value }: { value: string }) => props.onSelect(value)}
+                    options={options}
+                    optionRenderer={option => <span>{option.label}</span>}
+                    value={selectedQualityGateId}
+                  />
+                </div>
+              </div>
+            </Radio>
+
+            {needsReanalysis && (
+              <Alert className="big-spacer-top" variant="warning">
+                {translate('project_quality_gate.requires_new_analysis')}
+              </Alert>
+            )}
+          </div>
+
+          <div>
+            <SubmitButton disabled={submitting}>{translate('save')}</SubmitButton>
+            {submitting && <i className="spinner spacer-left" />}
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/App-test.tsx
deleted file mode 100644 (file)
index 00f3ee3..0000000
+++ /dev/null
@@ -1,145 +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.
- */
-/* eslint-disable import/first */
-jest.mock('../../../api/quality-gates', () => ({
-  associateGateWithProject: jest.fn(() => Promise.resolve()),
-  dissociateGateWithProject: jest.fn(() => Promise.resolve()),
-  fetchQualityGates: jest.fn(() => Promise.resolve({})),
-  getGateForProject: jest.fn(() => Promise.resolve())
-}));
-
-jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({
-  default: jest.fn()
-}));
-
-jest.mock('../../../app/utils/handleRequiredAuthorization', () => ({
-  default: jest.fn()
-}));
-
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import App from '../App';
-
-const associateGateWithProject = require('../../../api/quality-gates')
-  .associateGateWithProject as jest.Mock<any>;
-
-const dissociateGateWithProject = require('../../../api/quality-gates')
-  .dissociateGateWithProject as jest.Mock<any>;
-
-const fetchQualityGates = require('../../../api/quality-gates').fetchQualityGates as jest.Mock<any>;
-
-const getGateForProject = require('../../../api/quality-gates').getGateForProject as jest.Mock<any>;
-
-const addGlobalSuccessMessage = require('../../../app/utils/addGlobalSuccessMessage')
-  .default as jest.Mock<any>;
-
-const handleRequiredAuthorization = require('../../../app/utils/handleRequiredAuthorization')
-  .default as jest.Mock<any>;
-
-const component = {
-  analysisDate: '',
-  breadcrumbs: [],
-  configuration: { showQualityGates: true },
-  key: 'component',
-  name: 'component',
-  organization: 'org',
-  qualifier: 'TRK',
-  version: '0.0.1'
-} as T.Component;
-
-beforeEach(() => {
-  associateGateWithProject.mockClear();
-  dissociateGateWithProject.mockClear();
-  addGlobalSuccessMessage.mockClear();
-});
-
-it('checks permissions', () => {
-  handleRequiredAuthorization.mockClear();
-  shallow(
-    <App
-      component={{ ...component, configuration: undefined } as T.Component}
-      onComponentChange={jest.fn()}
-    />
-  );
-  expect(handleRequiredAuthorization).toBeCalled();
-});
-
-it('fetches quality gates', () => {
-  fetchQualityGates.mockClear();
-  getGateForProject.mockClear();
-  shallow(<App component={component} onComponentChange={jest.fn()} />);
-  expect(fetchQualityGates).toBeCalledWith({ organization: 'org' });
-  expect(getGateForProject).toBeCalledWith({ organization: 'org', project: 'component' });
-});
-
-it('changes quality gate from custom to default', () => {
-  const gate = randomGate('foo');
-  const allGates = [gate, randomGate('bar', true), randomGate('baz')];
-  const wrapper = mountRender(allGates, gate);
-  wrapper.find('Form').prop<Function>('onChange')('foo', 'bar');
-  expect(associateGateWithProject).toBeCalledWith({
-    gateId: 'bar',
-    organization: 'org',
-    projectKey: 'component'
-  });
-});
-
-it('changes quality gate from custom to custom', () => {
-  const allGates = [randomGate('foo'), randomGate('bar', true), randomGate('baz')];
-  const wrapper = mountRender(allGates, randomGate('foo'));
-  wrapper.find('Form').prop<Function>('onChange')('foo', 'baz');
-  expect(associateGateWithProject).toBeCalledWith({
-    gateId: 'baz',
-    organization: 'org',
-    projectKey: 'component'
-  });
-});
-
-it('changes quality gate from custom to none', () => {
-  const allGates = [randomGate('foo'), randomGate('bar'), randomGate('baz')];
-  const wrapper = mountRender(allGates, randomGate('foo'));
-  wrapper.find('Form').prop<Function>('onChange')('foo', undefined);
-  expect(dissociateGateWithProject).toBeCalledWith({
-    gateId: 'foo',
-    organization: 'org',
-    projectKey: 'component'
-  });
-});
-
-it('changes quality gate from none to custom', () => {
-  const allGates = [randomGate('foo'), randomGate('bar'), randomGate('baz')];
-  const wrapper = mountRender(allGates);
-  wrapper.find('Form').prop<Function>('onChange')(undefined, 'baz');
-  expect(associateGateWithProject).toBeCalledWith({
-    gateId: 'baz',
-    organization: 'org',
-    projectKey: 'component'
-  });
-});
-
-function randomGate(id: string, isDefault = false) {
-  return { id, isDefault, name: id };
-}
-
-function mountRender(allGates: any[], gate?: any) {
-  const wrapper = shallow(<App component={component} onComponentChange={jest.fn()} />);
-  wrapper.setState({ allGates, loading: false, gate });
-  return wrapper;
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Form-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Form-test.tsx
deleted file mode 100644 (file)
index b69f987..0000000
+++ /dev/null
@@ -1,48 +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 Form from '../Form';
-
-it('renders', () => {
-  const foo = randomGate('1');
-  const allGates = [foo, randomGate('2')];
-  expect(shallow(<Form allGates={allGates} gate={foo} onChange={jest.fn()} />)).toMatchSnapshot();
-});
-
-it('changes quality gate', () => {
-  const allGates = [randomGate('1'), randomGate('2')];
-  const onChange = jest.fn(() => Promise.resolve());
-  const wrapper = shallow(<Form allGates={allGates} onChange={onChange} />);
-
-  wrapper.find('Select').prop<Function>('onChange')({ value: '2' });
-  expect(onChange).lastCalledWith(undefined, '2');
-
-  wrapper.setProps({ gate: randomGate('1') });
-  wrapper.find('Select').prop<Function>('onChange')({ value: '2' });
-  expect(onChange).lastCalledWith('1', '2');
-});
-
-function randomGate(id: string) {
-  return {
-    id,
-    name: `name-${id}`
-  };
-}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__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/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx
new file mode 100644 (file)
index 0000000..7f1d4bd
--- /dev/null
@@ -0,0 +1,178 @@
+/*
+ * 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 {
+  associateGateWithProject,
+  dissociateGateWithProject,
+  fetchQualityGates,
+  getGateForProject,
+  searchProjects
+} from '../../../api/quality-gates';
+import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization';
+import { mockQualityGate } from '../../../helpers/mocks/quality-gates';
+import { mockComponent } from '../../../helpers/testMocks';
+import { USE_SYSTEM_DEFAULT } from '../constants';
+import ProjectQualityGateApp from '../ProjectQualityGateApp';
+
+jest.mock('../../../api/quality-gates', () => {
+  const { mockQualityGate } = jest.requireActual('../../../helpers/mocks/quality-gates');
+
+  const gate1 = mockQualityGate();
+  const gate2 = mockQualityGate({ id: '2', isBuiltIn: true });
+  const gate3 = mockQualityGate({ id: '3', isDefault: true });
+
+  return {
+    associateGateWithProject: jest.fn().mockResolvedValue(null),
+    dissociateGateWithProject: jest.fn().mockResolvedValue(null),
+    fetchQualityGates: jest.fn().mockResolvedValue({
+      qualitygates: [gate1, gate2, gate3]
+    }),
+    getGateForProject: jest.fn().mockResolvedValue(gate2),
+    searchProjects: jest.fn().mockResolvedValue({ 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 user permissions', () => {
+  shallowRender({ component: mockComponent({ configuration: { showQualityGates: false } }) });
+  expect(handleRequiredAuthorization).toBeCalled();
+});
+
+it('correctly loads Quality Gate data', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(fetchQualityGates).toBeCalled();
+  expect(getGateForProject).toBeCalledWith({ project: 'foo' });
+
+  expect(wrapper.state().allQualityGates).toHaveLength(3);
+  expect(wrapper.state().currentQualityGate?.id).toBe('2');
+  expect(wrapper.state().selectedQualityGateId).toBe('2');
+});
+
+it('correctly fallbacks to the default Quality Gate', async () => {
+  (getGateForProject as jest.Mock).mockResolvedValueOnce(
+    mockQualityGate({ id: '3', isDefault: true })
+  );
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(searchProjects).toBeCalled();
+
+  expect(wrapper.state().currentQualityGate?.id).toBe('3');
+  expect(wrapper.state().selectedQualityGateId).toBe(USE_SYSTEM_DEFAULT);
+});
+
+it('correctly detects if the default Quality Gate was explicitly selected', async () => {
+  (getGateForProject as jest.Mock).mockResolvedValueOnce(
+    mockQualityGate({ id: '3', isDefault: true })
+  );
+  (searchProjects as jest.Mock).mockResolvedValueOnce({
+    results: [{ key: 'foo', selected: true }]
+  });
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(searchProjects).toBeCalled();
+
+  expect(wrapper.state().currentQualityGate?.id).toBe('3');
+  expect(wrapper.state().selectedQualityGateId).toBe('3');
+});
+
+it('correctly associates a selected Quality Gate', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleSelect('3');
+  wrapper.instance().handleSubmit();
+
+  expect(associateGateWithProject).toHaveBeenCalledWith({
+    gateId: '3',
+    projectKey: 'foo'
+  });
+});
+
+it('correctly associates a project with the system default Quality Gate', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.setState({
+    currentQualityGate: mockQualityGate({ id: '1' }),
+    selectedQualityGateId: USE_SYSTEM_DEFAULT
+  });
+  wrapper.instance().handleSubmit();
+
+  expect(dissociateGateWithProject).toHaveBeenCalledWith({
+    gateId: '1',
+    projectKey: 'foo'
+  });
+});
+
+it("correctly doesn't do anything if the system default was selected, and the project had no prior Quality Gate associated with it", async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.setState({ currentQualityGate: undefined, selectedQualityGateId: USE_SYSTEM_DEFAULT });
+  wrapper.instance().handleSubmit();
+
+  expect(associateGateWithProject).not.toHaveBeenCalled();
+  expect(dissociateGateWithProject).not.toHaveBeenCalled();
+});
+
+it('correctly handles WS errors', async () => {
+  (fetchQualityGates as jest.Mock).mockRejectedValueOnce(null);
+  (getGateForProject as jest.Mock).mockRejectedValueOnce(null);
+  (searchProjects as jest.Mock).mockRejectedValueOnce(null);
+
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state().allQualityGates).toBeUndefined();
+  expect(wrapper.state().currentQualityGate).toBeUndefined();
+  expect(wrapper.state().loading).toBe(false);
+
+  const result = await wrapper.instance().isUsingDefault(mockQualityGate());
+  expect(result).toBe(false);
+});
+
+function shallowRender(props: Partial<ProjectQualityGateApp['props']> = {}) {
+  return shallow<ProjectQualityGateApp>(
+    <ProjectQualityGateApp
+      component={mockComponent({ key: 'foo', configuration: { showQualityGates: true } })}
+      onComponentChange={jest.fn()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx
new file mode 100644 (file)
index 0000000..76ed132
--- /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 } 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 { submit } from 'sonar-ui-common/helpers/testUtils';
+import { mockQualityGate } from '../../../helpers/mocks/quality-gates';
+import { USE_SYSTEM_DEFAULT } from '../constants';
+import ProjectQualityGateAppRenderer, {
+  ProjectQualityGateAppRendererProps
+} from '../ProjectQualityGateAppRenderer';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
+  expect(shallowRender({ submitting: true })).toMatchSnapshot('submitting');
+  expect(
+    shallowRender({
+      currentQualityGate: mockQualityGate({ id: '2', isDefault: true }),
+      selectedQualityGateId: USE_SYSTEM_DEFAULT
+    })
+  ).toMatchSnapshot('always use system default');
+  expect(
+    shallowRender({
+      selectedQualityGateId: '5'
+    })
+  ).toMatchSnapshot('show warning');
+  expect(
+    shallowRender({
+      selectedQualityGateId: USE_SYSTEM_DEFAULT
+    })
+  ).toMatchSnapshot('show warning if not using default');
+  expect(shallowRender({ allQualityGates: undefined }).type()).toBeNull(); // no quality gates
+});
+
+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: '1', label: 'Gate 1' })).toMatchSnapshot('default');
+      resolve();
+    }
+  });
+});
+
+it('should correctly handle changes', () => {
+  const wrapper = shallowRender();
+  const onSelect = jest.fn(selectedQualityGateId => {
+    wrapper.setProps({ selectedQualityGateId });
+  });
+  wrapper.setProps({ onSelect });
+
+  wrapper
+    .find(Radio)
+    .at(0)
+    .props()
+    .onCheck(USE_SYSTEM_DEFAULT);
+  expect(onSelect).toHaveBeenLastCalledWith(USE_SYSTEM_DEFAULT);
+
+  wrapper
+    .find(Radio)
+    .at(1)
+    .props()
+    .onCheck('1');
+  expect(onSelect).toHaveBeenLastCalledWith('1');
+
+  wrapper.find(Select).props().onChange!({ value: '2' });
+  expect(onSelect).toHaveBeenLastCalledWith('2');
+});
+
+it('should correctly handle form submission', () => {
+  const onSubmit = jest.fn();
+  const wrapper = shallowRender({ onSubmit });
+  submit(wrapper.find('form'));
+  expect(onSubmit).toBeCalled();
+});
+
+function shallowRender(props: Partial<ProjectQualityGateAppRendererProps> = {}) {
+  return shallow<ProjectQualityGateAppRendererProps>(
+    <ProjectQualityGateAppRenderer
+      allQualityGates={[mockQualityGate(), mockQualityGate({ id: '2', isDefault: true })]}
+      currentQualityGate={mockQualityGate({ id: '1' })}
+      loading={false}
+      onSelect={jest.fn()}
+      onSubmit={jest.fn()}
+      selectedQualityGateId="1"
+      submitting={false}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Form-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Form-test.tsx.snap
deleted file mode 100644 (file)
index e7620af..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders 1`] = `
-<div>
-  <Select
-    clearable={false}
-    disabled={false}
-    onChange={[Function]}
-    optionRenderer={[Function]}
-    options={
-      Array [
-        Object {
-          "isDefault": undefined,
-          "label": "name-1",
-          "value": "1",
-        },
-        Object {
-          "isDefault": undefined,
-          "label": "name-2",
-          "value": "2",
-        },
-      ]
-    }
-    style={
-      Object {
-        "width": 300,
-      }
-    }
-    value="1"
-    valueRenderer={[Function]}
-  />
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Header-test.tsx.snap
deleted file mode 100644 (file)
index 234232f..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_gate.page
-    </h1>
-    <HelpTooltip
-      className="spacer-left"
-      overlay={
-        <div
-          className="big-padded-top big-padded-bottom"
-        >
-          quality_gates.projects.help
-        </div>
-      }
-    />
-  </div>
-  <div
-    className="page-description"
-  >
-    project_quality_gate.page.description
-  </div>
-</header>
-`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateApp-test.tsx.snap
new file mode 100644 (file)
index 0000000..d758540
--- /dev/null
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+<ProjectQualityGateAppRenderer
+  loading={true}
+  onSelect={[Function]}
+  onSubmit={[Function]}
+  selectedQualityGateId="-1"
+  submitting={false}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap
new file mode 100644 (file)
index 0000000..26e95c1
--- /dev/null
@@ -0,0 +1,743 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: always use system default 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-gate"
+>
+  <Suggestions
+    suggestions="project_quality_gate"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_gate.page"
+  />
+  <A11ySkipTarget
+    anchor="qg_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_gate.page
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_gates.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_gate.subtitle
+    </h2>
+    <form
+      className="boxed-group-inner"
+      onSubmit={[Function]}
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_gate.page.description
+      </p>
+      <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_gate.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted little-spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              qualitygate
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={false}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="2"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_gate.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": "qualitygate",
+                      "value": "1",
+                    },
+                    Object {
+                      "label": "qualitygate",
+                      "value": "2",
+                    },
+                  ]
+                }
+                value="-1"
+              />
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div>
+        <SubmitButton
+          disabled={false}
+        >
+          save
+        </SubmitButton>
+      </div>
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: default 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-gate"
+>
+  <Suggestions
+    suggestions="project_quality_gate"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_gate.page"
+  />
+  <A11ySkipTarget
+    anchor="qg_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_gate.page
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_gates.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_gate.subtitle
+    </h2>
+    <form
+      className="boxed-group-inner"
+      onSubmit={[Function]}
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_gate.page.description
+      </p>
+      <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_gate.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted little-spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              qualitygate
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <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_gate.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": "qualitygate",
+                      "value": "1",
+                    },
+                    Object {
+                      "label": "qualitygate",
+                      "value": "2",
+                    },
+                  ]
+                }
+                value="1"
+              />
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div>
+        <SubmitButton
+          disabled={false}
+        >
+          save
+        </SubmitButton>
+      </div>
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<i
+  className="spinner"
+/>
+`;
+
+exports[`should render correctly: show warning 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-gate"
+>
+  <Suggestions
+    suggestions="project_quality_gate"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_gate.page"
+  />
+  <A11ySkipTarget
+    anchor="qg_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_gate.page
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_gates.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_gate.subtitle
+    </h2>
+    <form
+      className="boxed-group-inner"
+      onSubmit={[Function]}
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_gate.page.description
+      </p>
+      <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_gate.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted little-spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              qualitygate
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={true}
+          className="display-flex-start"
+          disabled={false}
+          onCheck={[Function]}
+          value="5"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_gate.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": "qualitygate",
+                      "value": "1",
+                    },
+                    Object {
+                      "label": "qualitygate",
+                      "value": "2",
+                    },
+                  ]
+                }
+                value="5"
+              />
+            </div>
+          </div>
+        </Radio>
+        <Alert
+          className="big-spacer-top"
+          variant="warning"
+        >
+          project_quality_gate.requires_new_analysis
+        </Alert>
+      </div>
+      <div>
+        <SubmitButton
+          disabled={false}
+        >
+          save
+        </SubmitButton>
+      </div>
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: show warning if not using default 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-gate"
+>
+  <Suggestions
+    suggestions="project_quality_gate"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_gate.page"
+  />
+  <A11ySkipTarget
+    anchor="qg_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_gate.page
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_gates.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_gate.subtitle
+    </h2>
+    <form
+      className="boxed-group-inner"
+      onSubmit={[Function]}
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_gate.page.description
+      </p>
+      <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_gate.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted little-spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              qualitygate
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <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_gate.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": "qualitygate",
+                      "value": "1",
+                    },
+                    Object {
+                      "label": "qualitygate",
+                      "value": "2",
+                    },
+                  ]
+                }
+                value="-1"
+              />
+            </div>
+          </div>
+        </Radio>
+        <Alert
+          className="big-spacer-top"
+          variant="warning"
+        >
+          project_quality_gate.requires_new_analysis
+        </Alert>
+      </div>
+      <div>
+        <SubmitButton
+          disabled={false}
+        >
+          save
+        </SubmitButton>
+      </div>
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: submitting 1`] = `
+<div
+  className="page page-limited"
+  id="project-quality-gate"
+>
+  <Suggestions
+    suggestions="project_quality_gate"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    title="project_quality_gate.page"
+  />
+  <A11ySkipTarget
+    anchor="qg_main"
+  />
+  <header
+    className="page-header"
+  >
+    <div
+      className="page-title display-flex-center"
+    >
+      <h1>
+        project_quality_gate.page
+      </h1>
+      <HelpTooltip
+        className="spacer-left"
+        overlay={
+          <div
+            className="big-padded-top big-padded-bottom"
+          >
+            quality_gates.projects.help
+          </div>
+        }
+      />
+    </div>
+  </header>
+  <div
+    className="boxed-group"
+  >
+    <h2
+      className="boxed-group-header"
+    >
+      project_quality_gate.subtitle
+    </h2>
+    <form
+      className="boxed-group-inner"
+      onSubmit={[Function]}
+    >
+      <p
+        className="big-spacer-bottom"
+      >
+        project_quality_gate.page.description
+      </p>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={false}
+          className="display-flex-start"
+          disabled={true}
+          onCheck={[Function]}
+          value="-1"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_gate.always_use_default
+            </div>
+            <div
+              className="display-flex-center"
+            >
+              <span
+                className="text-muted little-spacer-right"
+              >
+                current_noun
+                :
+              </span>
+              qualitygate
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div
+        className="big-spacer-bottom"
+      >
+        <Radio
+          checked={true}
+          className="display-flex-start"
+          disabled={true}
+          onCheck={[Function]}
+          value="1"
+        >
+          <div
+            className="spacer-left"
+          >
+            <div
+              className="little-spacer-bottom"
+            >
+              project_quality_gate.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": "qualitygate",
+                      "value": "1",
+                    },
+                    Object {
+                      "label": "qualitygate",
+                      "value": "2",
+                    },
+                  ]
+                }
+                value="1"
+              />
+            </div>
+          </div>
+        </Radio>
+      </div>
+      <div>
+        <SubmitButton
+          disabled={true}
+        >
+          save
+        </SubmitButton>
+        <i
+          className="spinner spacer-left"
+        />
+      </div>
+    </form>
+  </div>
+</div>
+`;
+
+exports[`should render select options correctly: default 1`] = `
+<span>
+  Gate 1
+</span>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/constants.ts b/server/sonar-web/src/main/js/apps/projectQualityGate/constants.ts
new file mode 100644 (file)
index 0000000..fa3f316
--- /dev/null
@@ -0,0 +1,21 @@
+/*
+ * 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..0390755918bbcbab02510dfec70e45efb124a9cc 100644 (file)
@@ -21,7 +21,7 @@ import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent'
 
 const routes = [
   {
-    indexRoute: { component: lazyLoadComponent(() => import('./App')) }
+    indexRoute: { component: lazyLoadComponent(() => import('./ProjectQualityGateApp')) }
   }
 ];
 
index ca73a073d833d652fbc0b808059e83a702e9f775..bd49bac6d31640fcb684f0fcadcad2d714a924b5 100644 (file)
@@ -52,6 +52,8 @@ create_new_element=Create new element
 created=Created
 created_on=Created on
 critical=Critical
+current=current
+current_noun=Current
 customize=Customize
 date=Date
 days=Days
@@ -1358,6 +1360,10 @@ project_quality_profile.successfully_updated={0} Quality Profile has been succes
 #------------------------------------------------------------------------------
 project_quality_gate.default_qgate=Default
 project_quality_gate.successfully_updated=Quality Gate has been successfully updated.
+project_quality_gate.subtitle=Manage project Quality Gate
+project_quality_gate.always_use_default=Always use the instance default Quality Gate
+project_quality_gate.always_use_specific=Always use a specific Quality Gate
+project_quality_gate.requires_new_analysis=Changes will be applied after the next analysis.
 
 #------------------------------------------------------------------------------
 #
@@ -1485,7 +1491,7 @@ quality_gates.conditions=Conditions
 quality_gates.conditions.help=Both conditions on New Code and Overall Code have to be met by a project to pass the Quality Gate.
 quality_gates.conditions.help.link=See also: Clean as You Code
 quality_gates.projects=Projects
-quality_gates.projects.help=The Default gate is applied to all projects not explicitly assigned to a gate. Quality Profile and Gate administrators can assign projects to a gate from the Quality Profile page. Project administrators can also choose a non-default gate.
+quality_gates.projects.help=The Default gate is applied to all projects not explicitly assigned to a gate. Quality Gate administrators can assign projects to a non-default gate, or always make it follow the system default. Project administrators may choose any gate.
 quality_gates.add_condition=Add Condition
 quality_gates.condition_added=Successfully added condition.
 quality_gates.update_condition=Update Condition