]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23299 Update add condition dialog in QG
authorstanislavh <stanislav.honcharov@sonarsource.com>
Tue, 29 Oct 2024 16:18:21 +0000 (17:18 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 5 Nov 2024 20:03:02 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/quality-gates/components/AddConditionModal.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/ConditionOperator.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/MetricSelect.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/ThresholdInput.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index fb5a05cc892120354b96fa198eaf3a824983d7c3..dbf4802d8dc6a50de86ffb1b4ffaed63d5764693 100644 (file)
@@ -27,10 +27,16 @@ import { useMetrics } from '../../../app/components/metrics/withMetricsContext';
 import { translate } from '../../../helpers/l10n';
 import { isDiffMetric } from '../../../helpers/measures';
 import { useCreateConditionMutation } from '../../../queries/quality-gates';
+import { useStandardExperienceMode } from '../../../queries/settings';
 import { MetricKey, MetricType } from '../../../sonar-aligned/types/metrics';
 import { Feature } from '../../../types/features';
 import { Condition, Metric, QualityGate } from '../../../types/types';
-import { getPossibleOperators, isNonEditableMetric } from '../utils';
+import {
+  getPossibleOperators,
+  isNonEditableMetric,
+  MQR_CONDITIONS_MAP,
+  STANDARD_CONDITIONS_MAP,
+} from '../utils';
 import ConditionOperator from './ConditionOperator';
 import MetricSelect from './MetricSelect';
 import ThresholdInput from './ThresholdInput';
@@ -45,29 +51,14 @@ const FORBIDDEN_METRICS: string[] = [
   MetricKey.releasability_rating,
   MetricKey.security_hotspots,
   MetricKey.new_security_hotspots,
-  MetricKey.software_quality_maintainability_rating,
-  MetricKey.new_software_quality_maintainability_rating,
-  MetricKey.software_quality_reliability_rating,
-  MetricKey.new_software_quality_reliability_rating,
-  MetricKey.software_quality_security_rating,
-  MetricKey.new_software_quality_security_rating,
-  MetricKey.effort_to_reach_software_quality_maintainability_rating_a,
-  MetricKey.software_quality_maintainability_remediation_effort,
-  MetricKey.new_software_quality_maintainability_remediation_effort,
-  MetricKey.software_quality_security_remediation_effort,
-  MetricKey.new_software_quality_security_remediation_effort,
-  MetricKey.software_quality_reliability_remediation_effort,
-  MetricKey.new_software_quality_reliability_remediation_effort,
-  MetricKey.software_quality_maintainability_debt_ratio,
-  MetricKey.new_software_quality_maintainability_debt_ratio,
 ];
 
 const ADD_CONDITION_MODAL_ID = 'add-condition-modal';
 
 export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
+  const { data: isStandardMode } = useStandardExperienceMode();
   const [open, setOpen] = React.useState(false);
   const closeModal = React.useCallback(() => setOpen(false), []);
-
   const [errorThreshold, setErrorThreshold] = React.useState('');
   const [scope, setScope] = React.useState<'new' | 'overall'>('new');
   const [selectedMetric, setSelectedMetric] = React.useState<Metric | undefined>();
@@ -83,6 +74,11 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
 
   const { conditions = [] } = qualityGate;
 
+  const similarMetricFromAnotherMode = findSimilarConditionMetricFromAnotherMode(
+    qualityGate.conditions,
+    selectedMetric,
+  );
+
   const availableMetrics = React.useMemo(() => {
     return differenceWith(
       map(metrics, (metric) => metric).filter(
@@ -90,6 +86,11 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
           !metric.hidden &&
           !FORBIDDEN_METRIC_TYPES.includes(metric.type) &&
           !FORBIDDEN_METRICS.includes(metric.key) &&
+          !(
+            isStandardMode
+              ? Object.values(STANDARD_CONDITIONS_MAP)
+              : Object.values(MQR_CONDITIONS_MAP)
+          ).includes(metric.key as MetricKey) &&
           !(
             metric.key === MetricKey.prioritized_rule_issues &&
             !hasFeature(Feature.PrioritizedRules)
@@ -98,7 +99,7 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
       conditions,
       (metric, condition) => metric.key === condition.metric,
     );
-  }, [conditions, hasFeature, metrics]);
+  }, [conditions, hasFeature, metrics, isStandardMode]);
 
   const handleFormSubmit = React.useCallback(
     async (event: React.FormEvent<HTMLFormElement>) => {
@@ -167,6 +168,7 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
           label={translate('quality_gates.conditions.fails_when')}
         >
           <MetricSelect
+            similarMetricFromAnotherMode={similarMetricFromAnotherMode}
             selectedMetric={selectedMetric}
             metricsArray={availableMetrics.filter((m) =>
               scope === 'new' ? isDiffMetric(m.key) : !isDiffMetric(m.key),
@@ -183,6 +185,7 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
               label={translate('quality_gates.conditions.operator')}
             >
               <ConditionOperator
+                isDisabled={Boolean(similarMetricFromAnotherMode)}
                 metric={selectedMetric}
                 onOperatorChange={handleOperatorChange}
                 op={selectedOperator}
@@ -194,7 +197,10 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
             >
               <ThresholdInput
                 metric={selectedMetric}
-                disabled={isNonEditableMetric(selectedMetric.key as MetricKey)}
+                disabled={
+                  isNonEditableMetric(selectedMetric.key as MetricKey) ||
+                  Boolean(similarMetricFromAnotherMode)
+                }
                 name="error"
                 onChange={handleErrorChange}
                 value={errorThreshold}
@@ -214,7 +220,7 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
       onOpenChange={setOpen}
       primaryButton={
         <Button
-          isDisabled={selectedMetric === undefined}
+          isDisabled={selectedMetric === undefined || Boolean(similarMetricFromAnotherMode)}
           id="add-condition-button"
           form={ADD_CONDITION_MODAL_ID}
           type="submit"
@@ -231,3 +237,23 @@ export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
     </Modal>
   );
 }
+
+function findSimilarConditionMetricFromAnotherMode(
+  conditions: Condition[] = [],
+  selectedMetric?: Metric,
+) {
+  if (!selectedMetric) {
+    return undefined;
+  }
+
+  const selectedMetricFromAnotherMode =
+    STANDARD_CONDITIONS_MAP[selectedMetric.key as MetricKey] ??
+    MQR_CONDITIONS_MAP[selectedMetric.key as MetricKey];
+
+  if (!selectedMetricFromAnotherMode) {
+    return undefined;
+  }
+
+  const qgMetrics = conditions.map((condition) => condition.metric);
+  return qgMetrics.find((metric) => metric === selectedMetricFromAnotherMode);
+}
index e497de4ad1ca637140dec40731306f95fba078a4..47b7641bf78d1b9a07da048497e1a145ce03135e 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import * as React from 'react';
-import { InputSelect, Note } from '~design-system';
+import { InputSize, Select } from '@sonarsource/echoes-react';
+import { Note } from '~design-system';
 import { getOperatorLabel } from '../../../helpers/qualityGates';
 import { Metric } from '../../../types/types';
 import { getPossibleOperators } from '../utils';
 
 interface Props {
+  isDisabled?: boolean;
   metric: Metric;
   onOperatorChange: (op: string) => void;
   op?: string;
 }
 
-export default class ConditionOperator extends React.PureComponent<Props> {
-  handleChange = ({ value }: { label: string; value: string }) => {
-    this.props.onOperatorChange(value);
-  };
+export default function ConditionOperator(props: Readonly<Props>) {
+  const operators = getPossibleOperators(props.metric);
 
-  render() {
-    const operators = getPossibleOperators(this.props.metric);
-
-    if (Array.isArray(operators)) {
-      const operatorOptions = operators.map((op) => {
-        const label = getOperatorLabel(op, this.props.metric);
-        return { label, value: op };
-      });
-
-      return (
-        <InputSelect
-          autoFocus
-          size="small"
-          isClearable={false}
-          inputId="condition-operator"
-          name="operator"
-          onChange={this.handleChange}
-          options={operatorOptions}
-          isSearchable={false}
-          value={operatorOptions.filter((o) => o.value === this.props.op)}
-        />
-      );
-    }
-
-    return <Note className="sw-w-abs-150">{getOperatorLabel(operators, this.props.metric)}</Note>;
+  if (!Array.isArray(operators)) {
+    return <Note className="sw-w-abs-150">{getOperatorLabel(operators, props.metric)}</Note>;
   }
+  const operatorOptions = operators.map((op) => {
+    const label = getOperatorLabel(op, props.metric);
+    return { label, value: op };
+  });
+
+  return (
+    <Select
+      isDisabled={props.isDisabled}
+      size={InputSize.Small}
+      id="condition-operator"
+      isNotClearable
+      onChange={props.onOperatorChange}
+      data={operatorOptions}
+      value={operatorOptions.find((o) => o.value === props.op)?.value}
+    />
+  );
 }
index bc9fa482b675c6ccfacc69f8a522e7fd79577f45..8b281972057fa65f4906af639c90a29e14eef407 100644 (file)
 import { Select } from '@sonarsource/echoes-react';
 import { groupBy, sortBy } from 'lodash';
 import * as React from 'react';
+import { useIntl } from 'react-intl';
 import withMetricsContext from '../../../app/components/metrics/withMetricsContext';
 import { translate } from '../../../helpers/l10n';
 import { isDefined } from '../../../helpers/types';
+import { MetricKey } from '../../../sonar-aligned/types/metrics';
 import { Dict, Metric } from '../../../types/types';
-import { getLocalizedMetricNameNoDiffMetric } from '../utils';
+import { getLocalizedMetricNameNoDiffMetric, STANDARD_CONDITIONS_MAP } from '../utils';
 
 interface Props {
   metrics: Dict<Metric>;
   metricsArray: Metric[];
   onMetricChange: (metric: Metric) => void;
   selectedMetric?: Metric;
+  similarMetricFromAnotherMode?: string;
 }
 
 export function MetricSelect({
@@ -39,7 +42,10 @@ export function MetricSelect({
   metricsArray,
   metrics,
   onMetricChange,
+  similarMetricFromAnotherMode,
 }: Readonly<Props>) {
+  const intl = useIntl();
+
   const handleChange = (key: string | null) => {
     if (isDefined(key)) {
       const selectedMetric = metricsArray.find((metric) => metric.key === key);
@@ -59,7 +65,19 @@ export function MetricSelect({
       data={options}
       value={selectedMetric?.key}
       onChange={handleChange}
-      ariaLabel={translate('quality_gates.conditions.fails_when')}
+      ariaLabel={intl.formatMessage({ id: 'quality_gates.conditions.fails_when' })}
+      labelError={
+        Boolean(similarMetricFromAnotherMode) &&
+        intl.formatMessage(
+          { id: 'quality_gates.add_condition.metric_from_other_mode' },
+          {
+            isStandardMode: Boolean(
+              STANDARD_CONDITIONS_MAP[similarMetricFromAnotherMode as MetricKey],
+            ),
+            metric: intl.formatMessage({ id: `metric.${similarMetricFromAnotherMode}.name` }),
+          },
+        )
+      }
       isSearchable
       isNotClearable
     />
index a05052ac1622a4fc4665103d1d527a89d04484d8..790a48f74385128e1a964f8a32d805ba8ef21623 100644 (file)
@@ -45,7 +45,7 @@ export default class ThresholdInput extends React.PureComponent<Props> {
   };
 
   renderRatingInput() {
-    const { name, value } = this.props;
+    const { name, value, disabled } = this.props;
 
     const options = [
       { label: 'A', value: '1' },
@@ -56,6 +56,7 @@ export default class ThresholdInput extends React.PureComponent<Props> {
 
     return (
       <InputSelect
+        isDisabled={disabled}
         className="sw-w-abs-150"
         inputId="condition-threshold"
         name={name}
index c30f631dd71f8ae563b62796da499f9abdb3ecaf..b07f9ee071ea9391267dd4bd42151f3f827582c2 100644 (file)
@@ -45,6 +45,7 @@ const ui = {
   qualityGateListItem: (qualityGateName: string) => byRole('link', { name: qualityGateName }),
   newConditionRow: byTestId('quality-gates__conditions-new').byRole('row'),
   overallConditionRow: byTestId('quality-gates__conditions-overall').byRole('row'),
+  addConditionButton: byRole('button', { name: 'quality_gates.add_condition' }),
   batchDialog: byRole('dialog', { name: /quality_gates.update_conditions.header/ }),
   singleDialog: byRole('dialog', { name: /quality_gates.update_conditions.header.single_metric/ }),
   updateMetricsBtn: byRole('button', { name: 'quality_gates.update_conditions.update_metrics' }),
@@ -1007,6 +1008,34 @@ describe('Mode transition', () => {
       expect(ui.singleUpdate.query()).not.toBeInTheDocument();
       expect(ui.standardBadge.query()).not.toBeInTheDocument();
     });
+
+    it('should not let adding condition if a similar one from another mode already added', async () => {
+      const user = userEvent.setup();
+      qualityGateHandler.setIsAdmin(true);
+      renderQualityGateApp();
+
+      expect(await ui.listItem.findAll()).toHaveLength(9);
+      await user.click(ui.qualityGateListItem('QG without new code conditions').get());
+      await user.click(await ui.addConditionButton.find());
+
+      const dialog = byRole('dialog');
+
+      await user.click(
+        dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get(),
+      );
+
+      // try adding Security Rating from MQR Mode
+      await user.click(
+        dialog.byRole('combobox', { name: 'quality_gates.conditions.fails_when' }).get(),
+      );
+      await user.click(dialog.byRole('option', { name: 'Security Rating' }).get());
+      expect(
+        byText(
+          'quality_gates.add_condition.metric_from_other_mode.true.metric.security_rating.name',
+        ).get(),
+      ).toBeInTheDocument();
+      expect(dialog.byRole('button', { name: 'quality_gates.add_condition' }).get()).toBeDisabled();
+    });
   });
 
   describe('Standard mode', () => {
@@ -1091,6 +1120,30 @@ describe('Mode transition', () => {
       expect(ui.singleUpdate.query()).not.toBeInTheDocument();
       expect(ui.mqrBadge.query()).not.toBeInTheDocument();
     });
+
+    it('should not let adding condition if a similar one from another mode already added', async () => {
+      const user = userEvent.setup();
+      qualityGateHandler.setIsAdmin(true);
+      renderQualityGateApp();
+
+      expect(await ui.listItem.findAll()).toHaveLength(9);
+      await user.click(ui.qualityGateListItem('QG with MQR conditions').get());
+      await user.click(await ui.addConditionButton.find());
+
+      const dialog = byRole('dialog');
+
+      // try adding blocker issues metric
+      await user.click(
+        dialog.byRole('combobox', { name: 'quality_gates.conditions.fails_when' }).get(),
+      );
+      await user.click(dialog.byRole('option', { name: 'Blocker Issues' }).get());
+      expect(
+        byText(
+          'quality_gates.add_condition.metric_from_other_mode.false.metric.new_software_quality_blocker_issues.name',
+        ).get(),
+      ).toBeInTheDocument();
+      expect(dialog.byRole('button', { name: 'quality_gates.add_condition' }).get()).toBeDisabled();
+    });
   });
 });
 
index d06d3c747b73fb745979dcccf1bdeb816a80e042..ab785a5a0ebb84f9dd5131ffc3eb5e1319eb1be0 100644 (file)
@@ -2443,6 +2443,7 @@ 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 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.add_condition.metric_from_other_mode=This quality gate already has an equivalent condition based on the same concept ("{metric}") that persists from the {isStandardMode, select, true {Standard Experience} other {Multi-Quality Rule Mode}}. Update the metric and you will be able to edit the condition.
 quality_gates.condition.edit=Edit condition on {0}
 quality_gates.condition.delete=Delete condition on {0}
 quality_gates.condition_added=Successfully added condition.