]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22813 and SONAR-22814 Use the new Modal and ModalAlert
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 26 Aug 2024 15:26:00 +0000 (17:26 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 28 Aug 2024 20:02:45 +0000 (20:02 +0000)
32 files changed:
server/sonar-web/__mocks__/@emotion/react.ts [new file with mode: 0644]
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/modal/Modal.tsx
server/sonar-web/package.json
server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx
server/sonar-web/src/main/js/apps/coding-rules/utils-tests.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PublicProjectDisclaimer.tsx
server/sonar-web/src/main/js/apps/permissions/test-utils.ts
server/sonar-web/src/main/js/apps/projectDeletion/__tests__/ProjectDeletionApp-it.tsx
server/sonar-web/src/main/js/apps/projectKey/__tests__/ProjectKeyApp-it.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/AddConditionModal.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/Condition.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/EditConditionModal.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/MetricSelect.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx
server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegrationRenderer.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/DeleteModal.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/ConfirmProvisioningModal.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitHubConfigurationForm.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/GitLabAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Github-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Gitlab-it.tsx
server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx
server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx
server/sonar-web/src/main/js/components/controls/ModalButton.tsx
server/sonar-web/src/main/js/components/permissions/usePermissionChange.tsx
server/sonar-web/yarn.lock

diff --git a/server/sonar-web/__mocks__/@emotion/react.ts b/server/sonar-web/__mocks__/@emotion/react.ts
new file mode 100644 (file)
index 0000000..1cae4be
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+
+/**
+ * Mock Global from emotion which doesn't like jsdom
+ * (Throws `TypeError: node.setAttribute is not a function`)
+ */
+function Global() {
+  return null;
+}
+
+module.exports = {
+  ...jest.requireActual('@emotion/react'),
+  Global,
+};
index 7781300089ca5a176f79397592331df808ad56a6..33aa253317c255f2009861482aaff6d1f1cf6e2f 100644 (file)
@@ -23,7 +23,7 @@
     "@babel/preset-typescript": "7.24.7",
     "@emotion/babel-plugin": "11.11.0",
     "@emotion/babel-plugin-jsx-pragmatic": "0.2.1",
-    "@sonarsource/echoes-react": "0.5.0",
+    "@sonarsource/echoes-react": "0.6.0",
     "@testing-library/dom": "10.2.0",
     "@testing-library/jest-dom": "6.4.6",
     "@testing-library/react": "16.0.0",
index 63589e5948d47786f64f3b4fc793a5e91074e656..680d0a501e444f6f1050310f36d9a44b990f71c1 100644 (file)
@@ -75,6 +75,29 @@ function hasNoChildren(props: Partial<Props>): props is PropsWithSections {
   return (props as PropsWithChildren).children === undefined;
 }
 
+/** @deprecated Use either Modal or ModalAlert from Echoes instead.
+ *
+ * The props have changed significantly:
+ * - `headerTitle` is now `title`
+ * - `headerDescription` is now `description` and is announced to screen readers.
+ * - `body` is replaced with `content`
+ * - `isLarge` is replaced with `size` (ModalSize.Default or ModalSize.Wide)
+ * - `isScrollable` and `isOverflowVisible` have been removed and the behavior is automatic!
+ * - `closeOnOverlayClick` has been removed and is either
+ *     - always false for ModalAlert (it requires an action)
+ *     or
+ *     - always true for Modal
+ *
+ * By default, the Modal will be controlled automatically by its Trigger (child element).
+ * This is the preferred way.
+ *
+ * If you need to control the Modal (e.g. open as a side effect, close after async action):
+ * - `onClose` has been removed. Instead, use:
+ * - `onOpenChange`: callback for `isOpen` value changes.
+ * - `IsOpen`: controls the display of the Modal (conditional rendering isn't necessary anymore)
+ *
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3465543707/Modals | Migration Guide} for more
+ */
 export function Modal({
   closeOnOverlayClick = true,
   isLarge,
index 8af78e79abdd0d97d45074c80c174bce3f7c60ff..c0d1ac4c33a25316e9b12851662eb31cb9b483fa 100644 (file)
@@ -13,7 +13,7 @@
     "@primer/octicons-react": "19.10.0",
     "@react-spring/rafz": "9.7.3",
     "@react-spring/web": "9.7.3",
-    "@sonarsource/echoes-react": "0.5.0",
+    "@sonarsource/echoes-react": "0.6.0",
     "@tanstack/react-query": "5.18.1",
     "axios": "1.7.2",
     "classnames": "2.5.1",
index 1a91d5e740adc9d00029e8fad1d692adec854339..da40c748a0b6a88e26514740f667732b7b8e3ab5 100644 (file)
@@ -149,18 +149,17 @@ export default class TaskActions extends React.PureComponent<Props, State> {
           )}
         </ActionsDropdown>
 
-        {this.state.cancelTaskOpen && (
-          <ConfirmModal
-            cancelButtonText={translate('close')}
-            confirmButtonText={translate('background_tasks.cancel_task')}
-            header={translate('background_tasks.cancel_task')}
-            isDestructive
-            onClose={this.closeCancelTask}
-            onConfirm={this.handleCancelTask}
-          >
-            {translate('background_tasks.cancel_task.text')}
-          </ConfirmModal>
-        )}
+        <ConfirmModal
+          cancelButtonText={translate('close')}
+          confirmButtonText={translate('background_tasks.cancel_task')}
+          header={translate('background_tasks.cancel_task')}
+          isDestructive
+          isOpen={this.state.cancelTaskOpen}
+          onClose={this.closeCancelTask}
+          onConfirm={this.handleCancelTask}
+        >
+          {translate('background_tasks.cancel_task.text')}
+        </ConfirmModal>
 
         {this.state.scannerContextOpen && (
           <ScannerContext onClose={this.closeScannerContext} task={task} />
index b2dedfd2226bee704385d4c942f9114a18594c9f..84c016051bac40f96f66bdf42f2dfc563bfc3e6c 100644 (file)
@@ -179,7 +179,7 @@ const selectors = {
   // Custom rule form
   createCustomRuleDialog: byRole('dialog', { name: 'coding_rules.create_custom_rule' }),
   updateCustomRuleDialog: byRole('dialog', { name: 'coding_rules.update_custom_rule' }),
-  deleteCustomRuleDialog: byRole('dialog', { name: 'coding_rules.delete_rule' }),
+  deleteCustomRuleDialog: byRole('alertdialog', { name: 'coding_rules.delete_rule' }),
   ruleNameTextbox: byRole('textbox', { name: 'name' }),
   keyTextbox: byRole('textbox', { name: 'key' }),
   cleanCodeCategorySelect: byRole('combobox', { name: 'category' }),
index c0d22a92660c2273851138286a1c9177cdc5d7ff..3741378ba1f4300645655fd483d0819cde1e6d0a 100644 (file)
@@ -378,13 +378,12 @@ class PermissionsProjectApp extends React.PureComponent<Props, State> {
               isLoading={loading}
             />
 
-            {disclaimer && (
-              <PublicProjectDisclaimer
-                component={component}
-                onClose={this.handleCloseDisclaimer}
-                onConfirm={this.handleTurnProjectToPublic}
-              />
-            )}
+            <PublicProjectDisclaimer
+              component={component}
+              onClose={this.handleCloseDisclaimer}
+              onConfirm={this.handleTurnProjectToPublic}
+              isOpen={disclaimer}
+            />
           </div>
 
           <AllHoldersList
index b2d68fc6ca90405800e54dd87b89e8adfd931f5d..7c649c75e27d37f0101a20958e50847272e2a33d 100644 (file)
@@ -28,11 +28,12 @@ interface Props {
     name: string;
     qualifier: string;
   };
+  isOpen: boolean;
   onClose: () => void;
   onConfirm: () => void;
 }
 
-export default function PublicProjectDisclaimer({ component, onClose, onConfirm }: Props) {
+export default function PublicProjectDisclaimer({ component, isOpen, onClose, onConfirm }: Props) {
   const { qualifier } = component;
   return (
     <ConfirmModal
@@ -40,6 +41,7 @@ export default function PublicProjectDisclaimer({ component, onClose, onConfirm
       header={translateWithParameters('projects_role.turn_x_to_public', component.name)}
       onClose={onClose}
       onConfirm={onConfirm}
+      isOpen={isOpen}
     >
       <FlagMessage className="sw-mb-4" variant="warning">
         {translate('projects_role.are_you_sure_to_turn_project_to_public.warning', qualifier)}
index 3c0f93d2bec742a63892704d01be1ce805361cd2..f39396bd8cb567cb2c1383237d67bae023f2096b 100644 (file)
@@ -44,7 +44,7 @@ export function getPageObject(user: UserEvent) {
     githubExplanations: byText('roles.page.description.github'),
     gitlabLogo: byRole('img', { name: 'project_permission.managed.alm.gitlab' }),
     gitlabExplanations: byText('roles.page.description.gitlab'),
-    confirmRemovePermissionDialog: byRole('dialog', {
+    confirmRemovePermissionDialog: byRole('alertdialog', {
       name: 'project_permission.remove_only_confirmation_title',
     }),
     nonGHProjectWarning: byText('project_permission.local_project_with_github_provisioning'),
index 4b0bc4294b47482f0e7f4703cca617995d53a34f..d57c66693f5dd89f37657c95cb269e82e520d892 100644 (file)
@@ -53,10 +53,11 @@ it('should be able to delete project', async () => {
 
   expect(byText('deletion.page').get()).toBeInTheDocument();
   expect(byText('project_deletion.page.description').get()).toBeInTheDocument();
-  await user.click(byRole('button', { name: 'delete' }).get());
-  expect(await byRole('dialog', { name: 'qualifier.delete.TRK' }).find()).toBeInTheDocument();
+  await user.click(ui.deleteButton.get());
+  expect(await ui.confirmationModal(ComponentQualifier.Project).find()).toBeInTheDocument();
+
   await user.click(
-    byRole('dialog', { name: 'qualifier.delete.TRK' }).byRole('button', { name: 'delete' }).get(),
+    ui.confirmationModal(ComponentQualifier.Project).byRole('button', { name: 'delete' }).get(),
   );
 
   expect(await byText(/project_deletion.resource_dele/).find()).toBeInTheDocument();
@@ -80,11 +81,11 @@ it('should be able to delete Portfolio', async () => {
   expect(byText('deletion.page').get()).toBeInTheDocument();
   expect(byText('portfolio_deletion.page.description').get()).toBeInTheDocument();
 
-  await user.click(byRole('button', { name: 'delete' }).get());
+  await user.click(ui.deleteButton.get());
 
-  expect(await byRole('dialog', { name: 'qualifier.delete.VW' }).find()).toBeInTheDocument();
+  expect(await ui.confirmationModal(ComponentQualifier.Portfolio).find()).toBeInTheDocument();
   await user.click(
-    byRole('dialog', { name: 'qualifier.delete.VW' }).byRole('button', { name: 'delete' }).get(),
+    ui.confirmationModal(ComponentQualifier.Portfolio).byRole('button', { name: 'delete' }).get(),
   );
 
   expect(await byText(/project_deletion.resource_dele/).find()).toBeInTheDocument();
@@ -108,10 +109,10 @@ it('should be able to delete Application', async () => {
   expect(byText('deletion.page').get()).toBeInTheDocument();
   expect(byText('application_deletion.page.description').get()).toBeInTheDocument();
 
-  await user.click(byRole('button', { name: 'delete' }).get());
-  expect(await byRole('dialog', { name: 'qualifier.delete.APP' }).find()).toBeInTheDocument();
+  await user.click(ui.deleteButton.get());
+  expect(await ui.confirmationModal(ComponentQualifier.Application).find()).toBeInTheDocument();
   await user.click(
-    byRole('dialog', { name: 'qualifier.delete.APP' }).byRole('button', { name: 'delete' }).get(),
+    ui.confirmationModal(ComponentQualifier.Application).byRole('button', { name: 'delete' }).get(),
   );
 
   expect(await byText(/project_deletion.resource_dele/).find()).toBeInTheDocument();
@@ -132,3 +133,9 @@ function renderProjectDeletionApp(component?: Component) {
     </ComponentContext.Provider>,
   );
 }
+
+const ui = {
+  confirmationModal: (qualifier: ComponentQualifier) =>
+    byRole('alertdialog', { name: `qualifier.delete.${qualifier}` }),
+  deleteButton: byRole('button', { name: 'delete' }),
+};
index a3b253285321396073522a112c29dbdce244d04f..7e91c2e6d484cd0076b66ec554bb11a2958e7379 100644 (file)
@@ -77,7 +77,7 @@ function getPageObjects() {
 
   const ui = {
     pageTitle: byRole('heading', { name: 'update_key.page' }),
-    updateKeyDialog: byRole('dialog'),
+    updateKeyDialog: byRole('alertdialog'),
     newKeyInput: byRole('textbox'),
     updateInputButton: byRole('button', { name: 'update_verb' }),
     resetInputButton: byRole('button', { name: 'reset_verb' }),
index ed785c588f4a0363757a20d1f7b030ee78e5b71d..4f0bfadf919e13afc27c84e5b721939bdfd3c6c6 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { Button, ButtonVariety, RadioButtonGroup } from '@sonarsource/echoes-react';
-import { FormField, Modal } from 'design-system';
+import { Button, ButtonVariety, Modal, RadioButtonGroup } from '@sonarsource/echoes-react';
+import { FormField } from 'design-system';
+import { differenceWith, map } from 'lodash';
 import * as React from 'react';
+import { useAvailableFeatures } from '../../../app/components/available-features/withAvailableFeatures';
+import { useMetrics } from '../../../app/components/metrics/withMetricsContext';
 import { translate } from '../../../helpers/l10n';
 import { isDiffMetric } from '../../../helpers/measures';
 import { useCreateConditionMutation } from '../../../queries/quality-gates';
-import { MetricKey } from '../../../sonar-aligned/types/metrics';
+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 ConditionOperator from './ConditionOperator';
@@ -31,38 +35,88 @@ import MetricSelect from './MetricSelect';
 import ThresholdInput from './ThresholdInput';
 
 interface Props {
-  metrics: Metric[];
-  onClose: () => void;
   qualityGate: QualityGate;
 }
 
+const FORBIDDEN_METRIC_TYPES = [MetricType.Data, MetricType.Distribution, 'STRING', 'BOOL'];
+const FORBIDDEN_METRICS: string[] = [
+  MetricKey.alert_status,
+  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.software_quality_security_review_rating,
+  MetricKey.new_software_quality_security_review_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({ metrics, onClose, qualityGate }: Readonly<Props>) {
+export default function AddConditionModal({ qualityGate }: Readonly<Props>) {
+  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>();
   const [selectedOperator, setSelectedOperator] = React.useState<string | undefined>();
   const { mutateAsync: createCondition } = useCreateConditionMutation(qualityGate.name);
+  const { hasFeature } = useAvailableFeatures();
+  const metrics = useMetrics();
 
   const getSinglePossibleOperator = (metric: Metric) => {
     const operators = getPossibleOperators(metric);
     return Array.isArray(operators) ? undefined : operators;
   };
 
-  const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
-    event.preventDefault();
-
-    if (selectedMetric) {
-      const newCondition: Omit<Condition, 'id'> = {
-        metric: selectedMetric.key,
-        op: getSinglePossibleOperator(selectedMetric) ?? selectedOperator,
-        error: errorThreshold,
-      };
-      await createCondition(newCondition);
-      onClose();
-    }
-  };
+  const { conditions = [] } = qualityGate;
+
+  const availableMetrics = React.useMemo(() => {
+    return differenceWith(
+      map(metrics, (metric) => metric).filter(
+        (metric) =>
+          !metric.hidden &&
+          !FORBIDDEN_METRIC_TYPES.includes(metric.type) &&
+          !FORBIDDEN_METRICS.includes(metric.key) &&
+          !(
+            metric.key === MetricKey.prioritized_rule_issues &&
+            !hasFeature(Feature.PrioritizedRules)
+          ),
+      ),
+      conditions,
+      (metric, condition) => metric.key === condition.metric,
+    );
+  }, [conditions, hasFeature, metrics]);
+
+  const handleFormSubmit = React.useCallback(
+    async (event: React.FormEvent<HTMLFormElement>) => {
+      event.preventDefault();
+
+      if (selectedMetric) {
+        const newCondition: Omit<Condition, 'id'> = {
+          metric: selectedMetric.key,
+          op: getSinglePossibleOperator(selectedMetric) ?? selectedOperator,
+          error: errorThreshold,
+        };
+        await createCondition(newCondition);
+        closeModal();
+      }
+    },
+    [closeModal, createCondition, errorThreshold, selectedMetric, selectedOperator],
+  );
 
   const handleScopeChange = (scope: 'new' | 'overall') => {
     let correspondingMetric;
@@ -70,7 +124,7 @@ export default function AddConditionModal({ metrics, onClose, qualityGate }: Rea
     if (selectedMetric) {
       const correspondingMetricKey =
         scope === 'new' ? `new_${selectedMetric.key}` : selectedMetric.key.replace(/^new_/, '');
-      correspondingMetric = metrics.find((m) => m.key === correspondingMetricKey);
+      correspondingMetric = availableMetrics.find((m) => m.key === correspondingMetricKey);
     }
 
     setScope(scope);
@@ -114,8 +168,8 @@ export default function AddConditionModal({ metrics, onClose, qualityGate }: Rea
           label={translate('quality_gates.conditions.fails_when')}
         >
           <MetricSelect
-            metric={selectedMetric}
-            metricsArray={metrics.filter((m) =>
+            selectedMetric={selectedMetric}
+            metricsArray={availableMetrics.filter((m) =>
               scope === 'new' ? isDiffMetric(m.key) : !isDiffMetric(m.key),
             )}
             onMetricChange={handleMetricChange}
@@ -155,14 +209,12 @@ export default function AddConditionModal({ metrics, onClose, qualityGate }: Rea
 
   return (
     <Modal
-      isScrollable={false}
-      isOverflowVisible
-      headerTitle={translate('quality_gates.add_condition')}
-      onClose={onClose}
-      body={renderBody()}
+      title={translate('quality_gates.add_condition')}
+      content={renderBody()}
+      isOpen={open}
+      onOpenChange={setOpen}
       primaryButton={
         <Button
-          hasAutoFocus
           isDisabled={selectedMetric === undefined}
           id="add-condition-button"
           form={ADD_CONDITION_MODAL_ID}
@@ -172,7 +224,11 @@ export default function AddConditionModal({ metrics, onClose, qualityGate }: Rea
           {translate('quality_gates.add_condition')}
         </Button>
       }
-      secondaryButtonLabel={translate('close')}
-    />
+      secondaryButton={<Button onClick={closeModal}>{translate('close')}</Button>}
+    >
+      <Button data-test="quality-gates__add-condition">
+        {translate('quality_gates.add_condition')}
+      </Button>
+    </Modal>
   );
 }
index 51ba56ea28d18f8f88d4c6eb71c5e676ddc0a943..0ef31a504e310b093151181d017709c7eed6ebaa 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import {
-  ActionCell,
-  ContentCell,
-  DangerButtonPrimary,
-  DestructiveIcon,
-  InteractiveIcon,
-  Modal,
-  NumericalCell,
-  PencilIcon,
-  TableRow,
-  TextError,
-  TrashIcon,
-} from 'design-system';
+  Button,
+  ButtonIcon,
+  ButtonSize,
+  ButtonVariety,
+  IconDelete,
+  ModalAlert,
+} from '@sonarsource/echoes-react';
+import { ActionCell, ContentCell, NumericalCell, TableRow, TextError } from 'design-system';
 import * as React from 'react';
 import { useMetrics } from '../../../app/components/metrics/withMetricsContext';
 import { getLocalizedMetricName, translate, translateWithParameters } from '../../../helpers/l10n';
@@ -68,28 +64,10 @@ export default function ConditionComponent({
   showEdit,
   isCaycModal,
 }: Readonly<Props>) {
-  const [deleteFormOpen, setDeleteFormOpen] = React.useState(false);
-  const [modal, setModal] = React.useState(false);
   const { mutateAsync: deleteCondition } = useDeleteConditionMutation(qualityGate.name);
   const metrics = useMetrics();
   const { op = 'GT' } = condition;
 
-  const handleOpenUpdate = () => {
-    setModal(true);
-  };
-
-  const handleUpdateClose = () => {
-    setModal(false);
-  };
-
-  const handleDeleteClick = () => {
-    setDeleteFormOpen(true);
-  };
-
-  const closeDeleteForm = () => {
-    setDeleteFormOpen(false);
-  };
-
   const isCaycCompliantAndOverCompliant = qualityGate.caycStatus !== CaycStatus.NonCompliant;
 
   return (
@@ -116,63 +94,36 @@ export default function ConditionComponent({
               !isConditionWithFixedValue(condition) ||
               (isCaycCompliantAndOverCompliant && showEdit)) &&
               !isNonEditableMetric(condition.metric as MetricKey) && (
-                <>
-                  <InteractiveIcon
-                    Icon={PencilIcon}
-                    aria-label={translateWithParameters(
-                      'quality_gates.condition.edit',
-                      metric.name,
-                    )}
-                    data-test="quality-gates__condition-update"
-                    onClick={handleOpenUpdate}
-                    className="sw-mr-4"
-                    size="small"
-                  />
-                  {modal && (
-                    <EditConditionModal
-                      condition={condition}
-                      header={translate('quality_gates.update_condition')}
-                      metric={metric}
-                      onClose={handleUpdateClose}
-                      qualityGate={qualityGate}
-                    />
-                  )}
-                </>
+                <EditConditionModal
+                  condition={condition}
+                  header={translate('quality_gates.update_condition')}
+                  metric={metric}
+                  qualityGate={qualityGate}
+                />
               )}
             {(!isCaycCompliantAndOverCompliant ||
               !condition.isCaycCondition ||
               (isCaycCompliantAndOverCompliant && showEdit)) && (
-              <>
-                <DestructiveIcon
-                  Icon={TrashIcon}
-                  aria-label={translateWithParameters(
-                    'quality_gates.condition.delete',
-                    metric.name,
-                  )}
-                  onClick={handleDeleteClick}
-                  size="small"
-                />
-                {deleteFormOpen && (
-                  <Modal
-                    headerTitle={translate('quality_gates.delete_condition')}
-                    onClose={closeDeleteForm}
-                    body={translateWithParameters(
-                      'quality_gates.delete_condition.confirm.message',
-                      getLocalizedMetricName(metric),
-                    )}
-                    primaryButton={
-                      <DangerButtonPrimary
-                        autoFocus
-                        type="submit"
-                        onClick={() => deleteCondition(condition)}
-                      >
-                        {translate('delete')}
-                      </DangerButtonPrimary>
-                    }
-                    secondaryButtonLabel={translate('close')}
-                  />
+              <ModalAlert
+                title={translate('quality_gates.delete_condition')}
+                description={translateWithParameters(
+                  'quality_gates.delete_condition.confirm.message',
+                  getLocalizedMetricName(metric),
                 )}
-              </>
+                primaryButton={
+                  <Button variety={ButtonVariety.Danger} onClick={() => deleteCondition(condition)}>
+                    {translate('delete')}
+                  </Button>
+                }
+                secondaryButtonLabel={translate('close')}
+              >
+                <ButtonIcon
+                  Icon={IconDelete}
+                  ariaLabel={translateWithParameters('quality_gates.condition.delete', metric.name)}
+                  size={ButtonSize.Medium}
+                  variety={ButtonVariety.DangerGhost}
+                />
+              </ModalAlert>
             )}
           </>
         )}
index b540822929a9c071ff93d5841f10293ab4cc4c7b..581878af0cc60dc81d6c435ef15fa7e01faf6583 100644 (file)
@@ -31,15 +31,14 @@ import {
   Spinner,
   SubHeading,
 } from 'design-system';
-import { differenceWith, map, uniqBy } from 'lodash';
+import { uniqBy } from 'lodash';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import DocHelpTooltip from '~sonar-aligned/components/controls/DocHelpTooltip';
-import { MetricKey } from '~sonar-aligned/types/metrics';
 import { useAvailableFeatures } from '../../../app/components/available-features/withAvailableFeatures';
 import { useMetrics } from '../../../app/components/metrics/withMetricsContext';
 import DocumentationLink from '../../../components/common/DocumentationLink';
-import ModalButton, { ModalProps } from '../../../components/controls/ModalButton';
+import { ModalProps } from '../../../components/controls/ModalButton';
 import { DocLink } from '../../../helpers/doc-links';
 import { useDocUrl } from '../../../helpers/docs';
 import { getLocalizedMetricName, translate } from '../../../helpers/l10n';
@@ -60,31 +59,6 @@ interface Props {
   qualityGate: QualityGate;
 }
 
-const FORBIDDEN_METRIC_TYPES = ['DATA', 'DISTRIB', 'STRING', 'BOOL'];
-const FORBIDDEN_METRICS: string[] = [
-  MetricKey.alert_status,
-  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.software_quality_security_review_rating,
-  MetricKey.new_software_quality_security_review_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,
-];
-
 export default function Conditions({ qualityGate, isFetching }: Readonly<Props>) {
   const [editing, setEditing] = React.useState<boolean>(
     qualityGate.caycStatus === CaycStatus.NonCompliant,
@@ -118,30 +92,6 @@ export default function Conditions({ qualityGate, isFetching }: Readonly<Props>)
     setEditing(qualityGate.caycStatus === CaycStatus.NonCompliant);
   }, [name]); // eslint-disable-line react-hooks/exhaustive-deps
 
-  const renderConditionModal = React.useCallback(
-    ({ onClose }: ModalProps) => {
-      const { conditions = [] } = qualityGate;
-      const availableMetrics = differenceWith(
-        map(metrics, (metric) => metric).filter(
-          (metric) =>
-            !metric.hidden &&
-            !FORBIDDEN_METRIC_TYPES.includes(metric.type) &&
-            !FORBIDDEN_METRICS.includes(metric.key) &&
-            !(
-              metric.key === MetricKey.prioritized_rule_issues &&
-              !hasFeature(Feature.PrioritizedRules)
-            ),
-        ),
-        conditions,
-        (metric, condition) => metric.key === condition.metric,
-      );
-      return (
-        <AddConditionModal metrics={availableMetrics} onClose={onClose} qualityGate={qualityGate} />
-      );
-    },
-    [metrics, qualityGate],
-  );
-
   const docUrl = useDocUrl(DocLink.CaYC);
   const isCompliantCustomQualityGate =
     qualityGate.caycStatus !== CaycStatus.NonCompliant && !qualityGate.isBuiltIn;
@@ -221,13 +171,7 @@ export default function Conditions({ qualityGate, isFetching }: Readonly<Props>)
         </div>
         <div>
           {(qualityGate.caycStatus === CaycStatus.NonCompliant || editing) && canEdit && (
-            <ModalButton modal={renderConditionModal}>
-              {({ onClick }) => (
-                <ButtonSecondary data-test="quality-gates__add-condition" onClick={onClick}>
-                  {translate('quality_gates.add_condition')}
-                </ButtonSecondary>
-              )}
-            </ModalButton>
+            <AddConditionModal qualityGate={qualityGate} />
           )}
         </div>
       </header>
index 7c9a805007da3d306583cdd68062264a4ef229be..27857edd757d86a7eb9d11e383c4dfe3489835e9 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { Button, ButtonVariety } from '@sonarsource/echoes-react';
-import { FormField, Highlight, Modal, Note } from 'design-system';
+import {
+  Button,
+  ButtonIcon,
+  ButtonSize,
+  ButtonVariety,
+  IconEdit,
+  Modal,
+} from '@sonarsource/echoes-react';
+import { FormField, Highlight, Note } from 'design-system';
 import { isArray } from 'lodash';
 import * as React from 'react';
-import { getLocalizedMetricName, translate } from '../../../helpers/l10n';
+import { getLocalizedMetricName, translate, translateWithParameters } from '../../../helpers/l10n';
 import { useUpdateConditionMutation } from '../../../queries/quality-gates';
 import { Condition, Metric, QualityGate } from '../../../types/types';
 import { getPossibleOperators } from '../utils';
@@ -32,18 +39,15 @@ interface Props {
   condition: Condition;
   header: string;
   metric: Metric;
-  onClose: () => void;
   qualityGate: QualityGate;
 }
 
 const EDIT_CONDITION_MODAL_ID = 'edit-condition-modal';
 
-export default function EditConditionModal({
-  condition,
-  metric,
-  onClose,
-  qualityGate,
-}: Readonly<Props>) {
+export default function EditConditionModal({ condition, metric, qualityGate }: Readonly<Props>) {
+  const [open, setOpen] = React.useState(false);
+  const [submitting, setSubmitting] = React.useState(false);
+
   const [errorThreshold, setErrorThreshold] = React.useState(condition ? condition.error : '');
 
   const [selectedOperator, setSelectedOperator] = React.useState<string | undefined>(
@@ -59,13 +63,21 @@ export default function EditConditionModal({
   const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
 
+    setSubmitting(true);
+
     const newCondition: Omit<Condition, 'id'> = {
       metric: metric.key,
       op: getSinglePossibleOperator(metric),
       error: errorThreshold,
     };
-    await updateCondition({ id: condition.id, ...newCondition });
-    onClose();
+    try {
+      await updateCondition({ id: condition.id, ...newCondition });
+      setOpen(false);
+    } catch (_) {
+      /* Error already handled */
+    }
+
+    setSubmitting(false);
   };
 
   const handleErrorChange = (error: string) => {
@@ -116,17 +128,34 @@ export default function EditConditionModal({
 
   return (
     <Modal
-      isScrollable={false}
-      isOverflowVisible
-      headerTitle={translate('quality_gates.update_condition')}
-      onClose={onClose}
-      body={renderBody()}
+      title={translate('quality_gates.update_condition')}
+      content={renderBody()}
       primaryButton={
-        <Button form={EDIT_CONDITION_MODAL_ID} type="submit" variety={ButtonVariety.Primary}>
+        <Button
+          form={EDIT_CONDITION_MODAL_ID}
+          isLoading={submitting}
+          type="submit"
+          variety={ButtonVariety.Primary}
+        >
           {translate('quality_gates.update_condition')}
         </Button>
       }
-      secondaryButtonLabel={translate('close')}
-    />
+      secondaryButton={
+        <Button variety={ButtonVariety.Default} onClick={() => setOpen(false)}>
+          {translate('close')}
+        </Button>
+      }
+      isOpen={open}
+      onOpenChange={setOpen}
+    >
+      <ButtonIcon
+        Icon={IconEdit}
+        ariaLabel={translateWithParameters('quality_gates.condition.edit', metric.name)}
+        data-test="quality-gates__condition-update"
+        className="sw-mr-4"
+        size={ButtonSize.Medium}
+        variety={ButtonVariety.PrimaryGhost}
+      />
+    </Modal>
   );
 }
index dd098646e4525aba3844670b516b8cd11d92afdf..73626d20cb1280fe049c59c07660487d018e9898 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
-import { sortBy } from 'lodash';
+import { Select, SelectOption } from '@sonarsource/echoes-react';
 import * as React from 'react';
-import { Options } from 'react-select';
 import withMetricsContext from '../../../app/components/metrics/withMetricsContext';
-import { getLocalizedMetricDomain, translate } from '../../../helpers/l10n';
+import { translate } from '../../../helpers/l10n';
+import { isDefined } from '../../../helpers/types';
 import { Dict, Metric } from '../../../types/types';
 import { getLocalizedMetricNameNoDiffMetric } from '../utils';
 
 interface Props {
-  metric?: Metric;
   metrics: Dict<Metric>;
   metricsArray: Metric[];
   onMetricChange: (metric: Metric) => void;
+  selectedMetric?: Metric;
 }
 
-interface Option {
-  isDisabled?: boolean;
-  label: string;
-  value: string;
-}
-
-export function MetricSelect({ metric, metricsArray, metrics, onMetricChange }: Readonly<Props>) {
-  const handleChange = (option: Option | null) => {
-    if (option) {
-      const selectedMetric = metricsArray.find((metric) => metric.key === option.value);
+export function MetricSelect({
+  selectedMetric,
+  metricsArray,
+  metrics,
+  onMetricChange,
+}: Readonly<Props>) {
+  const handleChange = (key: string | null) => {
+    if (isDefined(key)) {
+      const selectedMetric = metricsArray.find((metric) => metric.key === key);
       if (selectedMetric) {
         onMetricChange(selectedMetric);
       }
     }
   };
 
-  const options: Array<Option & { domain?: string }> = sortBy(
-    metricsArray.map((m) => ({
-      value: m.key,
-      label: getLocalizedMetricNameNoDiffMetric(m, metrics),
-      domain: m.domain,
-    })),
-    'domain',
-  );
-
-  // Use "disabled" property to emulate optgroups.
-  const optionsWithDomains: Option[] = [];
-  options.forEach((option, index, options) => {
-    const previous = index > 0 ? options[index - 1] : null;
-    if (option.domain && (!previous || previous.domain !== option.domain)) {
-      optionsWithDomains.push({
-        value: '<domain>',
-        label: getLocalizedMetricDomain(option.domain),
-        isDisabled: true,
-      });
-    }
-    optionsWithDomains.push(option);
-  });
-
-  const handleMetricsSearch = React.useCallback(
-    (query: string, resolve: (options: Options<LabelValueSelectOption<string>>) => void) => {
-      resolve(options.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())));
-    },
-    [options],
-  );
+  const options: SelectOption[] = metricsArray.map((m) => ({
+    value: m.key,
+    label: getLocalizedMetricNameNoDiffMetric(m, metrics),
+    group: m.domain,
+  }));
 
   return (
-    <SearchSelectDropdown
-      aria-label={translate('search.search_for_metrics')}
-      size="large"
-      controlSize="full"
-      inputId="condition-metric"
-      defaultOptions={optionsWithDomains}
-      loadOptions={handleMetricsSearch}
+    <Select
+      data={options}
+      value={selectedMetric?.key}
       onChange={handleChange}
-      placeholder={translate('search.search_for_metrics')}
-      controlLabel={
-        optionsWithDomains.find((o) => o.value === metric?.key)?.label ?? translate('select_verb')
-      }
+      ariaLabel={translate('quality_gates.conditions.fails_when')}
+      isSearchable
+      isNotClearable
     />
   );
 }
index 42f453267d21b2ac0987514870059d713abac4f0..047c41069152012360f4f3d64fa0fb3f585d6839 100644 (file)
@@ -213,7 +213,9 @@ it('should be able to add a condition on new code', async () => {
 
   await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.new_code' }).get());
 
-  await selectEvent.select(dialog.byRole('combobox').get(), 'Issues');
+  await user.click(dialog.byLabelText('quality_gates.conditions.fails_when').get());
+  await user.click(dialog.byRole('option', { name: 'Issues' }).get());
+
   await user.click(
     await dialog.byRole('textbox', { name: 'quality_gates.conditions.value' }).find(),
   );
@@ -236,10 +238,14 @@ it('should be able to add a condition on overall code', async () => {
 
   const dialog = byRole('dialog');
 
-  await selectEvent.select(dialog.byRole('combobox').get(), ['Info Issues']);
+  await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get());
+
+  await user.click(dialog.byLabelText('quality_gates.conditions.fails_when').get());
+
   // In real app there are no metrics with selectable condition operator
   // so we manually changed direction for Info Issues to 0 to test this behavior
-  await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get());
+  await user.click(await dialog.byRole('option', { name: 'Info Issues' }).find());
+
   await user.click(dialog.byLabelText('quality_gates.conditions.operator').get());
 
   await user.click(dialog.byText('quality_gates.operator.LT').get());
@@ -268,7 +274,9 @@ it('should be able to select a rating', async () => {
   const dialog = byRole('dialog');
 
   await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get());
-  await selectEvent.select(dialog.byRole('combobox').get(), ['Maintainability Rating']);
+  await user.click(dialog.byLabelText('quality_gates.conditions.fails_when').get());
+  await user.click(dialog.byRole('option', { name: 'Maintainability Rating' }).get());
+
   await user.click(dialog.byLabelText('quality_gates.conditions.value').get());
   await user.click(dialog.byText('B').get());
   await user.click(dialog.byRole('button', { name: 'quality_gates.add_condition' }).get());
@@ -334,7 +342,7 @@ it('should be able to handle delete condition', async () => {
     newConditions.getByLabelText('quality_gates.condition.delete.Coverage on New Code'),
   );
 
-  const dialog = within(screen.getByRole('dialog'));
+  const dialog = within(screen.getByRole('alertdialog'));
   await user.click(dialog.getByRole('button', { name: 'delete' }));
 
   await waitFor(() => {
@@ -570,7 +578,8 @@ it('should not allow to change value of prioritized_rule_issues', async () => {
   const dialog = byRole('dialog');
 
   await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get());
-  await selectEvent.select(dialog.byRole('combobox').get(), ['Issues from prioritized rules']);
+  await user.click(dialog.byLabelText('quality_gates.conditions.fails_when').get());
+  await user.click(dialog.byRole('option', { name: 'Issues from prioritized rules' }).get());
 
   expect(dialog.byRole('textbox', { name: 'quality_gates.conditions.value' }).get()).toBeDisabled();
   expect(dialog.byRole('textbox', { name: 'quality_gates.conditions.value' }).get()).toHaveValue(
index 1fa99d63eeaaf3f62cd5bb124df8bed89942b683..3a2e6c88dd6be44db7014429595b20d48b39c4dd 100644 (file)
@@ -72,6 +72,7 @@ const ui = {
   compareDropdown: byRole('combobox', { name: 'quality_profiles.compare_with' }),
   changelogLink: byRole('link', { name: 'changelog' }),
   popup: byRole('dialog'),
+  confirmationModal: byRole('alertdialog'),
   restoreProfileDialog: byRole('dialog', { name: 'quality_profiles.restore_profile' }),
   copyRadio: byRole('radio', {
     name: 'quality_profiles.creation_from_copy quality_profiles.creation_from_copy_description_1 quality_profiles.creation_from_copy_description_2',
@@ -357,7 +358,7 @@ it('should be able to activate or deactivate rules in comparison page', async ()
 
   // Deactivate
   await user.click(await ui.deactivateRuleButton('java quality profile #2').find());
-  expect(ui.popup.get()).toBeInTheDocument();
+  expect(ui.confirmationModal.get()).toBeInTheDocument();
   await user.click(ui.deactivateConfirmButton.get());
   expect(ui.summaryAdditionalRules(1).query()).not.toBeInTheDocument();
 });
index 10d6563266009fc4dbc95b38487644613f271565..e86daf521cfef7dfe12bdddbe67bdca991de309c 100644 (file)
@@ -165,14 +165,13 @@ export default function AlmIntegrationRenderer(props: AlmIntegrationRendererProp
         onUpdateDefinitions={props.onUpdateDefinitions}
       />
 
-      {isDefined(definitionKeyForDeletion) && (
-        <DeleteModal
-          id={definitionKeyForDeletion}
-          onCancel={props.onCancelDelete}
-          onDelete={props.onConfirmDelete}
-          projectCount={projectCount}
-        />
-      )}
+      <DeleteModal
+        id={definitionKeyForDeletion}
+        isOpen={isDefined(definitionKeyForDeletion)}
+        onCancel={props.onCancelDelete}
+        onDelete={props.onConfirmDelete}
+        projectCount={projectCount}
+      />
     </>
   );
 }
index 96b25eaea18655b2a7c73e6c3115b7115a7c0816..7334dd303d12e636f05fc12d4c2738831e29e930 100644 (file)
@@ -23,7 +23,8 @@ import ConfirmModal from '../../../../components/controls/ConfirmModal';
 import { translate, translateWithParameters } from '../../../../helpers/l10n';
 
 export interface DeleteModalProps {
-  id: string;
+  id?: string;
+  isOpen: boolean;
   onCancel: () => void;
   onDelete: (id: string) => void;
   projectCount?: number;
@@ -39,13 +40,20 @@ function showProjectCountWarning(projectCount?: number) {
   ) : null;
 }
 
-export default function DeleteModal({ id, onDelete, onCancel, projectCount }: DeleteModalProps) {
+export default function DeleteModal({
+  id,
+  isOpen,
+  onDelete,
+  onCancel,
+  projectCount,
+}: DeleteModalProps) {
   return (
     <ConfirmModal
       confirmButtonText={translate('delete')}
       confirmData={id}
       header={translate('settings.almintegration.delete.header')}
       isDestructive
+      isOpen={isOpen}
       onClose={onCancel}
       onConfirm={onDelete}
     >
index 7b430578b8bd9c58a5247e9f4a4b5a5213a89837..5d6af12b6edeb7d9bbf6fa262f6763c13e87a9ce 100644 (file)
@@ -28,6 +28,7 @@ interface Props {
   allowUsersToSignUp?: boolean;
   hasProvisioningTypeChange?: boolean;
   isAllowListEmpty: boolean;
+  isOpen: boolean;
   onClose: VoidFunction;
   onConfirm: VoidFunction;
   provider: Provider.Github | Provider.Gitlab;
@@ -39,6 +40,7 @@ export default function ConfirmProvisioningModal(props: Readonly<Props>) {
     allowUsersToSignUp,
     hasProvisioningTypeChange,
     isAllowListEmpty,
+    isOpen,
     onConfirm,
     onClose,
     provider,
@@ -49,6 +51,7 @@ export default function ConfirmProvisioningModal(props: Readonly<Props>) {
 
   return (
     <ConfirmModal
+      isOpen={isOpen}
       onConfirm={onConfirm}
       header={intl.formatMessage({
         id: `settings.authentication.${provider}.confirm.${hasProvisioningTypeChange ? provisioningStatus : 'insecure'}`,
index 7c024a157d2e6c3bdcfc4360df6fd06bed339243..37ec3ec70e002954a9f9469d07ff2f5b05e8ece6 100644 (file)
@@ -345,17 +345,20 @@ export default function GitHubAuthenticationTab() {
               provisioningType={provisioningType ?? ProvisioningType.jit}
               synchronizationDetails={<GitHubSynchronisationWarning />}
             />
-            {isConfirmProvisioningModalOpen && provisioningType && (
+
+            {provisioningType && (
               <ConfirmProvisioningModal
                 allowUsersToSignUp={allowUsersToSignUp}
                 hasProvisioningTypeChange={changes?.provisioningType !== undefined}
                 isAllowListEmpty={isEmpty(gitHubConfiguration.allowedOrganizations)}
+                isOpen={isConfirmProvisioningModalOpen}
                 onClose={() => setIsConfirmProvisioningModalOpen(false)}
                 onConfirm={onUpdateProvisioning}
                 provider={Provider.Github}
                 provisioningStatus={provisioningType}
               />
             )}
+
             {isMappingModalOpen && (
               <GitHubMappingModal
                 mapping={rolesMapping}
index 23e5a512e67909aee981d9eac5db56825bd27ebe..693953ba25f9b4b1f11023fb37923a75468e61d4 100644 (file)
@@ -17,8 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { Button, ButtonVariety, Spinner } from '@sonarsource/echoes-react';
-import { FlagMessage, Modal } from 'design-system';
+import { Button, ButtonVariety, Modal } from '@sonarsource/echoes-react';
+import { FlagMessage } from 'design-system';
 import { isEmpty, keyBy } from 'lodash';
 import React, { useEffect, useState } from 'react';
 import { FormattedMessage } from 'react-intl';
@@ -228,35 +228,34 @@ export default function GitHubConfigurationForm(props: Readonly<Props>) {
   return (
     <>
       <Modal
-        body={formBody}
-        headerTitle={header}
-        isScrollable
-        onClose={onClose}
+        content={formBody}
+        title={header}
+        isOpen
+        onOpenChange={onClose}
         primaryButton={
-          <>
-            <Spinner className="sw-ml-2" isLoading={isCreating || isUpdating} />
-            <Button
-              form={FORM_ID}
-              type="submit"
-              hasAutoFocus
-              isDisabled={!isFormValid}
-              variety={ButtonVariety.Primary}
-            >
-              <FormattedMessage id="settings.almintegration.form.save" />
-            </Button>
-          </>
+          <Button
+            form={FORM_ID}
+            type="submit"
+            hasAutoFocus
+            isDisabled={!isFormValid}
+            isLoading={isCreating || isUpdating}
+            variety={ButtonVariety.Primary}
+          >
+            <FormattedMessage id="settings.almintegration.form.save" />
+          </Button>
         }
+        secondaryButton={<Button onClick={onClose}>{translate('close')}</Button>}
+      />
+
+      <ConfirmProvisioningModal
+        allowUsersToSignUp={gitHubConfiguration?.allowUsersToSignUp}
+        isAllowListEmpty={isEmpty(gitHubConfiguration?.allowedOrganizations)}
+        isOpen={isConfirmModalOpen}
+        onClose={() => setIsConfirmModalOpen(false)}
+        onConfirm={onSave}
+        provider={Provider.Github}
+        provisioningStatus={gitHubConfiguration?.provisioningType ?? ProvisioningType.jit}
       />
-      {isConfirmModalOpen && (
-        <ConfirmProvisioningModal
-          allowUsersToSignUp={gitHubConfiguration?.allowUsersToSignUp}
-          isAllowListEmpty={isEmpty(gitHubConfiguration?.allowedOrganizations)}
-          onClose={() => setIsConfirmModalOpen(false)}
-          onConfirm={onSave}
-          provider={Provider.Github}
-          provisioningStatus={gitHubConfiguration?.provisioningType ?? ProvisioningType.jit}
-        />
-      )}
     </>
   );
 }
index 1499e8674f4b33e0210c2dd7c3363218676d6db7..67dcab52af5059bdd8c075a843d73ffa43c1d62f 100644 (file)
@@ -413,11 +413,12 @@ export default function GitLabAuthenticationTab() {
           </>
         )}
       </div>
-      {showConfirmProvisioningModal && provisioningType && (
+      {provisioningType && (
         <ConfirmProvisioningModal
           allowUsersToSignUp={allowUsersToSignUp}
           hasProvisioningTypeChange={Boolean(changes?.provisioningType)}
           isAllowListEmpty={isEmpty(allowedGroups)}
+          isOpen={showConfirmProvisioningModal}
           onClose={() => setShowConfirmProvisioningModal(false)}
           onConfirm={updateProvisioning}
           provider={Provider.Gitlab}
index 42550d1ab9d4144a81817b32e644771d5d9474a5..7715e16a60a463c278293f625697dcc4be147360 100644 (file)
@@ -204,24 +204,23 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) {
                 </>
               }
             />
-            {showConfirmProvisioningModal && (
-              <ConfirmModal
-                onConfirm={() => handleConfirmChangeProvisioning()}
-                header={translate(
-                  'settings.authentication.saml.confirm',
-                  newScimStatus ? 'scim' : 'jit',
-                )}
-                onClose={() => setShowConfirmProvisioningModal(false)}
-                isDestructive={!newScimStatus}
-                confirmButtonText={translate('yes')}
-              >
-                {translate(
-                  'settings.authentication.saml.confirm',
-                  newScimStatus ? 'scim' : 'jit',
-                  'description',
-                )}
-              </ConfirmModal>
-            )}
+            <ConfirmModal
+              onConfirm={() => handleConfirmChangeProvisioning()}
+              header={translate(
+                'settings.authentication.saml.confirm',
+                newScimStatus ? 'scim' : 'jit',
+              )}
+              onClose={() => setShowConfirmProvisioningModal(false)}
+              isDestructive={!newScimStatus}
+              isOpen={showConfirmProvisioningModal}
+              confirmButtonText={translate('yes')}
+            >
+              {translate(
+                'settings.authentication.saml.confirm',
+                newScimStatus ? 'scim' : 'jit',
+                'description',
+              )}
+            </ConfirmModal>
           </>
         )}
         {showEditModal && (
index 84dad7c9f3f696649dd4844eaa60539d7b5f001a..f7f8391ece16a6a6d31e93ab1dc902cdf829ccc1 100644 (file)
@@ -76,7 +76,7 @@ const ui = {
   textbox1: byRole('textbox', { name: 'test1' }),
   textbox2: byRole('textbox', { name: 'test2' }),
   tab: byRole('tab', { name: 'github GitHub' }),
-  cancelDialogButton: byRole('dialog').byRole('button', { name: 'cancel' }),
+  cancelDialogButton: byRole('alertdialog').byRole('button', { name: 'cancel' }),
   noGithubConfiguration: byText('settings.authentication.github.form.not_configured'),
   createConfigButton: ghContainer.byRole('button', {
     name: 'settings.authentication.form.create',
@@ -153,7 +153,7 @@ const ui = {
       name: `settings.definition.delete_value.property.allowedOrganizations.name.${org}`,
     }),
   enableFirstMessage: ghContainer.byText('settings.authentication.github.enable_first'),
-  insecureConfigWarning: byRole('dialog').byText(
+  insecureConfigWarning: byRole('alertdialog').byText(
     'settings.authentication.github.provisioning_change.insecure_config',
   ),
   jitProvisioningButton: ghContainer.byRole('radio', {
@@ -277,6 +277,7 @@ describe('Github tab', () => {
     await user.click(ui.deleteOrg('organization1').get());
 
     await user.click(ui.saveConfigButton.get());
+    await user.click(ui.confirmProvisioningButton.get());
 
     await user.click(await ui.editConfigButton.find());
 
@@ -985,7 +986,7 @@ describe('Github tab', () => {
       await user.click(ui.saveGithubProvisioning.get());
 
       expect(ui.insecureConfigWarning.get()).toBeInTheDocument();
-      await user.click(ui.confirmProvisioningButton.get());
+      await user.click(await ui.confirmProvisioningButton.find());
 
       await user.click(ui.projectVisibility.get());
       await user.click(ui.saveGithubProvisioning.get());
index 5d25656e2b39695b954f4b0e926095e9b1803b9f..d8735204ceef2027b05065e43ecb548e2de7da30 100644 (file)
@@ -118,10 +118,10 @@ const ui = {
   confirmAutoProvisioningDialog: byRole('dialog', {
     name: 'settings.authentication.gitlab.confirm.AUTO_PROVISIONING',
   }),
-  confirmJitProvisioningDialog: byRole('dialog', {
+  confirmJitProvisioningDialog: byRole('alertdialog', {
     name: 'settings.authentication.gitlab.confirm.JIT',
   }),
-  confirmInsecureProvisioningDialog: byRole('dialog', {
+  confirmInsecureProvisioningDialog: byRole('alertdialog', {
     name: 'settings.authentication.gitlab.confirm.insecure',
   }),
   confirmProvisioningChange: byRole('button', {
index 28f878067e738d73f8be3f171e37efc0504e0c3d..448c817abf8a0e8c3a49f5603344cf903e1ac468 100644 (file)
  * 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 Echoes from '@sonarsource/echoes-react';
 import * as React from 'react';
 import ConfirmModal, { ConfirmModalProps } from './ConfirmModal';
 import ModalButton, { ChildrenProps, ModalProps } from './ModalButton';
 
-interface Props<T> extends Omit<ConfirmModalProps<T>, 'children'> {
+interface Props<T> extends Omit<ConfirmModalProps<T>, 'children' | 'isOpen'> {
   children: (props: ChildrenProps) => React.ReactNode;
   modalBody: React.ReactNode;
   modalHeader: string;
@@ -32,6 +33,9 @@ interface State {
   modal: boolean;
 }
 
+/** @deprecated Use {@link Echoes.ModalAlert | ModalAlert} from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3465543707/Modals | Migration Guide}
+ */
 export default class ConfirmButton<T> extends React.PureComponent<Props<T>, State> {
   renderConfirmModal = ({ onClose }: ModalProps) => {
     const { children, modalBody, modalHeader, modalHeaderDescription, ...confirmModalProps } =
@@ -41,6 +45,7 @@ export default class ConfirmButton<T> extends React.PureComponent<Props<T>, Stat
         header={modalHeader}
         headerDescription={modalHeaderDescription}
         onClose={onClose}
+        isOpen
         {...confirmModalProps}
       >
         {modalBody}
index 3e20307767569adf5fe118f85c6845ed353050ea..8d460a59c8918ceeac90cf7f74d000d4eddd2f7a 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { ButtonPrimary, DangerButtonPrimary, Modal } from 'design-system';
+import * as Echoes from '@sonarsource/echoes-react';
+import { Button, ButtonVariety, ModalAlert } from '@sonarsource/echoes-react';
 import React from 'react';
 import { translate } from '../../helpers/l10n';
-import ClickEventBoundary from './ClickEventBoundary';
 
 export interface ConfirmModalProps<T> {
   cancelButtonText?: string;
@@ -29,6 +29,7 @@ export interface ConfirmModalProps<T> {
   confirmData?: T;
   confirmDisable?: boolean;
   isDestructive?: boolean;
+  isOpen: boolean;
   onConfirm: (data?: T) => void | Promise<void | Response>;
 }
 
@@ -38,6 +39,9 @@ interface Props<T> extends ConfirmModalProps<T> {
   onClose: () => void;
 }
 
+/** @deprecated Use {@link Echoes.ModalAlert | ModalAlert} from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3465543707/Modals | Migration Guide}
+ */
 export default function ConfirmModal<T = string>(props: Readonly<Props<T>>) {
   const {
     header,
@@ -49,6 +53,7 @@ export default function ConfirmModal<T = string>(props: Readonly<Props<T>>) {
     confirmDisable,
     headerDescription,
     isDestructive,
+    isOpen,
     cancelButtonText = translate('cancel'),
   } = props;
 
@@ -61,37 +66,38 @@ export default function ConfirmModal<T = string>(props: Readonly<Props<T>>) {
     if (result) {
       return result.then(
         () => {
+          setSubmitting(false);
           onClose();
         },
         () => {
-          /* noop */
+          setSubmitting(false);
         },
       );
     }
 
+    setSubmitting(false);
     onClose();
     return undefined;
   }, [confirmData, onClose, onConfirm, setSubmitting]);
 
-  const Button = isDestructive ? DangerButtonPrimary : ButtonPrimary;
-
   return (
-    <Modal
-      headerTitle={header}
-      headerDescription={headerDescription}
-      body={
-        <ClickEventBoundary>
-          <>{children}</>
-        </ClickEventBoundary>
-      }
+    <ModalAlert
+      title={header}
+      description={headerDescription}
+      isOpen={isOpen}
+      onOpenChange={onClose}
+      content={children}
       primaryButton={
-        <Button autoFocus disabled={submitting || confirmDisable} onClick={handleSubmit}>
+        <Button
+          variety={isDestructive ? ButtonVariety.Danger : ButtonVariety.Primary}
+          isDisabled={submitting || confirmDisable}
+          isLoading={submitting}
+          onClick={handleSubmit}
+        >
           {confirmButtonText}
         </Button>
       }
       secondaryButtonLabel={cancelButtonText}
-      loading={submitting}
-      onClose={onClose}
     />
   );
 }
index 8abdbb7f70d25550448485b1db94100623c14067..0a2f9eecea0bf3fddfe9d43f6e2249cb0a89710b 100644 (file)
@@ -17,6 +17,7 @@
  * 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 Echoes from '@sonarsource/echoes-react';
 import * as React from 'react';
 
 export interface ChildrenProps {
@@ -37,6 +38,9 @@ interface State {
   modal: boolean;
 }
 
+/** @deprecated Use either {@link Echoes.Modal | Modal} or {@link Echoes.ModalAlert | ModalAlert} from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3465543707/Modals | Migration Guide}
+ */
 export default class ModalButton extends React.PureComponent<Props, State> {
   mounted = false;
   state: State = { modal: false };
index 2bfb7c3a38471d331be089f3183a3233721a113d..1d9381a70f263b1014f2110392c9b46ff3224033 100644 (file)
@@ -79,6 +79,7 @@ export default function usePermissionChange<T extends PermissionGroup | Permissi
           confirmButtonText={translate('confirm')}
           header={translate('project_permission.remove_only_confirmation_title')}
           isDestructive
+          isOpen
           onClose={() => setConfirmPermission(null)}
           onConfirm={() => handleChangePermission(confirmPermission.key)}
         >
index b8ae3384cbb713a6028ef357f7135bbae62f5faf..9cf64a7db50aa7e0fe9b7306b94fc0c60bf4a635 100644 (file)
@@ -3861,9 +3861,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@mantine/core@patch:@mantine/core@6.0.21#./config/patches/@mantine-core-npm-6.0.21-4d202d6649.patch::locator=%40sonarsource%2Fechoes-react%40npm%3A0.5.0":
+"@mantine/core@patch:@mantine/core@6.0.21#./config/patches/@mantine-core-npm-6.0.21-4d202d6649.patch::locator=%40sonarsource%2Fechoes-react%40npm%3A0.6.0":
   version: 6.0.21
-  resolution: "@mantine/core@patch:@mantine/core@npm%3A6.0.21#./config/patches/@mantine-core-npm-6.0.21-4d202d6649.patch::version=6.0.21&hash=070d0f&locator=%40sonarsource%2Fechoes-react%40npm%3A0.5.0"
+  resolution: "@mantine/core@patch:@mantine/core@npm%3A6.0.21#./config/patches/@mantine-core-npm-6.0.21-4d202d6649.patch::version=6.0.21&hash=070d0f&locator=%40sonarsource%2Fechoes-react%40npm%3A0.6.0"
   dependencies:
     "@floating-ui/react": "npm:^0.19.1"
     "@mantine/styles": "npm:6.0.21"
@@ -4177,6 +4177,30 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@radix-ui/react-alert-dialog@npm:1.1.1":
+  version: 1.1.1
+  resolution: "@radix-ui/react-alert-dialog@npm:1.1.1"
+  dependencies:
+    "@radix-ui/primitive": "npm:1.1.0"
+    "@radix-ui/react-compose-refs": "npm:1.1.0"
+    "@radix-ui/react-context": "npm:1.1.0"
+    "@radix-ui/react-dialog": "npm:1.1.1"
+    "@radix-ui/react-primitive": "npm:2.0.0"
+    "@radix-ui/react-slot": "npm:1.1.0"
+  peerDependencies:
+    "@types/react": "*"
+    "@types/react-dom": "*"
+    react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+  peerDependenciesMeta:
+    "@types/react":
+      optional: true
+    "@types/react-dom":
+      optional: true
+  checksum: 10/5049255a4b31b2d65c36235c32016c54234f7580778639b416c3c169af545a2522f9f452d6eb1d9d31c0d2e4d9ca2ae36cac4876bec31f5cc422e92acba5557c
+  languageName: node
+  linkType: hard
+
 "@radix-ui/react-arrow@npm:1.1.0":
   version: 1.1.0
   resolution: "@radix-ui/react-arrow@npm:1.1.0"
@@ -4292,6 +4316,38 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@radix-ui/react-dialog@npm:1.1.1":
+  version: 1.1.1
+  resolution: "@radix-ui/react-dialog@npm:1.1.1"
+  dependencies:
+    "@radix-ui/primitive": "npm:1.1.0"
+    "@radix-ui/react-compose-refs": "npm:1.1.0"
+    "@radix-ui/react-context": "npm:1.1.0"
+    "@radix-ui/react-dismissable-layer": "npm:1.1.0"
+    "@radix-ui/react-focus-guards": "npm:1.1.0"
+    "@radix-ui/react-focus-scope": "npm:1.1.0"
+    "@radix-ui/react-id": "npm:1.1.0"
+    "@radix-ui/react-portal": "npm:1.1.1"
+    "@radix-ui/react-presence": "npm:1.1.0"
+    "@radix-ui/react-primitive": "npm:2.0.0"
+    "@radix-ui/react-slot": "npm:1.1.0"
+    "@radix-ui/react-use-controllable-state": "npm:1.1.0"
+    aria-hidden: "npm:^1.1.1"
+    react-remove-scroll: "npm:2.5.7"
+  peerDependencies:
+    "@types/react": "*"
+    "@types/react-dom": "*"
+    react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+  peerDependenciesMeta:
+    "@types/react":
+      optional: true
+    "@types/react-dom":
+      optional: true
+  checksum: 10/8c4b4af680e306db4fe113e9e19eb173f633b40b267030906167f6f62adfaa02aa2cb8ee97775ad0c701242a5ef6d0ce3528e1b13e640cbc6ea356eb27836189
+  languageName: node
+  linkType: hard
+
 "@radix-ui/react-direction@npm:1.0.0":
   version: 1.0.0
   resolution: "@radix-ui/react-direction@npm:1.0.0"
@@ -5059,14 +5115,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@sonarsource/echoes-react@npm:0.5.0":
-  version: 0.5.0
-  resolution: "@sonarsource/echoes-react@npm:0.5.0"
+"@sonarsource/echoes-react@npm:0.6.0":
+  version: 0.6.0
+  resolution: "@sonarsource/echoes-react@npm:0.6.0"
   dependencies:
     "@mantine/core": "patch:@mantine/core@6.0.21#./config/patches/@mantine-core-npm-6.0.21-4d202d6649.patch"
     "@mantine/hooks": "npm:6.0.21"
     "@material-symbols/font-400": "npm:0.21.1"
+    "@radix-ui/react-alert-dialog": "npm:1.1.1"
     "@radix-ui/react-checkbox": "npm:1.1.1"
+    "@radix-ui/react-dialog": "npm:1.1.1"
     "@radix-ui/react-dropdown-menu": "npm:2.1.1"
     "@radix-ui/react-popover": "npm:1.1.1"
     "@radix-ui/react-radio-group": "npm:1.2.0"
@@ -5081,7 +5139,7 @@ __metadata:
     react-dom: ^17.0.0 || ^18.0.0
     react-intl: ^6.0.0
     react-router-dom: ^6.0.0
-  checksum: 10/ffe4b12cce1cf8a66ce89c8fd255d288292a73542de87806d2283553aa44804a90e37a559230d224a6b5fc20a245463969fc350cc742e13bab35355c9853f4bf
+  checksum: 10/bfeca900430708f94e7b8f8fb9e8aef2c55028b1e621eb6df6d192fdeffd63d67f929715ca600cb36936af81b6df1738502a065ba2947fc23755d8780988db62
   languageName: node
   linkType: hard
 
@@ -6379,7 +6437,7 @@ __metadata:
     "@primer/octicons-react": "npm:19.10.0"
     "@react-spring/rafz": "npm:9.7.3"
     "@react-spring/web": "npm:9.7.3"
-    "@sonarsource/echoes-react": "npm:0.5.0"
+    "@sonarsource/echoes-react": "npm:0.6.0"
     "@swc/core": "npm:1.6.6"
     "@swc/jest": "npm:0.2.36"
     "@tanstack/react-query": "npm:5.18.1"
@@ -8494,7 +8552,7 @@ __metadata:
     "@babel/preset-typescript": "npm:7.24.7"
     "@emotion/babel-plugin": "npm:11.11.0"
     "@emotion/babel-plugin-jsx-pragmatic": "npm:0.2.1"
-    "@sonarsource/echoes-react": "npm:0.5.0"
+    "@sonarsource/echoes-react": "npm:0.6.0"
     "@testing-library/dom": "npm:10.2.0"
     "@testing-library/jest-dom": "npm:6.4.6"
     "@testing-library/react": "npm:16.0.0"