From 691fe9681e59a01bca5e6079e74c2d3995ba3667 Mon Sep 17 00:00:00 2001 From: Viktor Vorona Date: Thu, 17 Oct 2024 18:03:21 +0200 Subject: [PATCH] SONAR-23188 Custom design for the mode setting --- .../main/js/api/mocks/SettingsServiceMock.ts | 9 ++ .../components/AdditionalCategories.tsx | 14 ++ .../main/js/apps/settings/components/Mode.tsx | 141 ++++++++++++++++++ .../settings/components/__tests__/Mode-it.tsx | 119 +++++++++++++++ .../components/__tests__/SettingsApp-it.tsx | 8 + .../src/main/js/apps/settings/constants.ts | 1 + .../src/main/js/helpers/doc-links.ts | 2 + .../sonar-web/src/main/js/queries/settings.ts | 27 +++- .../sonar/core/config/MQRModeProperties.java | 1 + .../resources/org/sonar/l10n/core.properties | 13 ++ 10 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/settings/components/Mode.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/__tests__/Mode-it.tsx diff --git a/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts index 70822c677ee..4300806e713 100644 --- a/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts @@ -121,6 +121,15 @@ export const DEFAULT_DEFINITIONS_MOCK = [ description: 'Lets do it', type: SettingType.BOOLEAN, }), + mockDefinition({ + category: 'Mode', + defaultValue: 'true', + key: 'sonar.multi-quality-mode.enabled', + name: 'Enable Multi-Quality Rule Mode', + options: [], + subCategory: 'Mode', + type: SettingType.BOOLEAN, + }), ]; export default class SettingsServiceMock { diff --git a/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx b/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx index 7681bab2df4..2fd013f470b 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx @@ -28,12 +28,14 @@ import { CODE_FIX_CATEGORY, EMAIL_NOTIFICATION_CATEGORY, LANGUAGES_CATEGORY, + MODE_CATEGORY, NEW_CODE_PERIOD_CATEGORY, PULL_REQUEST_DECORATION_BINDING_CATEGORY, } from '../constants'; import { AnalysisScope } from './AnalysisScope'; import CodeFixAdmin from './CodeFixAdmin'; import Languages from './Languages'; +import { Mode } from './Mode'; import NewCodeDefinition from './NewCodeDefinition'; import AlmIntegration from './almIntegration/AlmIntegration'; import Authentication from './authentication/Authentication'; @@ -123,6 +125,14 @@ export const ADDITIONAL_CATEGORIES: AdditionalCategory[] = [ availableForProject: false, displayTab: true, }, + { + key: MODE_CATEGORY, + name: translate('settings.mode.title'), + renderComponent: getModeComponent, + availableGlobally: true, + availableForProject: false, + displayTab: true, + }, ]; function getLanguagesComponent(props: AdditionalCategoryComponentProps) { @@ -156,3 +166,7 @@ function getPullRequestDecorationBindingComponent(props: AdditionalCategoryCompo function getEmailNotificationComponent() { return ; } + +function getModeComponent() { + return ; +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/Mode.tsx b/server/sonar-web/src/main/js/apps/settings/components/Mode.tsx new file mode 100644 index 00000000000..a5242c88eba --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/Mode.tsx @@ -0,0 +1,141 @@ +/* + * 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. + */ + +import { + Button, + ButtonGroup, + ButtonVariety, + Heading, + Spinner, + Text, + TextSize, +} from '@sonarsource/echoes-react'; +import { SelectionCard } from 'design-system'; +import * as React from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import DocumentationLink from '../../../components/common/DocumentationLink'; +import { DocLink } from '../../../helpers/doc-links'; +import { useSaveSimpleValueMutation, useStandardExperienceMode } from '../../../queries/settings'; +import { SettingsKey } from '../../../types/settings'; + +export function Mode() { + const { data: isStandardMode, isLoading } = useStandardExperienceMode(); + const { mutate: setMode, isPending } = useSaveSimpleValueMutation(true); + const [changedMode, setChangedMode] = React.useState(false); + const intl = useIntl(); + + const handleSave = () => { + // we need to invert because on BE we store isMQRMode + setMode( + { value: String(!!isStandardMode), key: SettingsKey.MQRMode }, + { onSuccess: () => setChangedMode(false) }, + ); + }; + + return ( + <> + + {intl.formatMessage({ id: 'settings.mode.title' })} + + + + {intl.formatMessage({ id: 'settings.mode.mqr.name' })} + + ), + standardLink: ( + + {intl.formatMessage({ id: 'settings.mode.standard.name' })} + + ), + }} + /> + +
+
+ + {intl.formatMessage({ id: 'settings.mode.description.line2' })} + + +
+ setChangedMode(isStandardMode === false)} + selected={changedMode ? !isStandardMode : isStandardMode} + title={intl.formatMessage({ id: 'settings.mode.standard.name' })} + > +
+ {intl.formatMessage({ id: 'settings.mode.standard.description.line1' })} +
+
+ {intl.formatMessage({ id: 'settings.mode.standard.description.line2' })} +
+
+ setChangedMode(isStandardMode === true)} + selected={changedMode ? isStandardMode : !isStandardMode} + title={intl.formatMessage({ id: 'settings.mode.mqr.name' })} + > +
+ {intl.formatMessage({ id: 'settings.mode.mqr.description.line1' })} +
+
+ {intl.formatMessage({ id: 'settings.mode.mqr.description.line2' })} +
+
+
+
+ + + + {changedMode && ( + <> + + + + + + + {intl.formatMessage({ id: 'settings.mode.save.warning' })} + + + )} + + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/Mode-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/Mode-it.tsx new file mode 100644 index 00000000000..6074657a57d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/Mode-it.tsx @@ -0,0 +1,119 @@ +/* + * 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. + */ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { byRole, byText } from '~sonar-aligned/helpers/testSelector'; +import SettingsServiceMock from '../../../../api/mocks/SettingsServiceMock'; +import { definitions } from '../../../../helpers/mocks/definitions-list'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { SettingsKey } from '../../../../types/settings'; +import { Mode } from '../Mode'; + +let settingServiceMock: SettingsServiceMock; + +beforeAll(() => { + settingServiceMock = new SettingsServiceMock(); + settingServiceMock.setDefinitions(definitions); +}); + +afterEach(() => { + settingServiceMock.reset(); +}); + +const ui = { + standard: byRole('radio', { name: /settings.mode.standard/ }), + mqr: byRole('radio', { name: /settings.mode.mqr/ }), + saveButton: byRole('button', { name: /settings.mode.save/ }), + cancelButton: byRole('button', { name: 'cancel' }), + saveWarning: byText('settings.mode.save.warning'), +}; + +it('should be able to select standard mode', async () => { + const user = userEvent.setup(); + renderMode(); + + expect(await ui.standard.find()).toBeInTheDocument(); + expect(ui.mqr.get()).toBeChecked(); + expect(ui.standard.get()).not.toBeChecked(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + expect(ui.cancelButton.query()).not.toBeInTheDocument(); + expect(ui.saveWarning.query()).not.toBeInTheDocument(); + + await user.click(ui.standard.get()); + expect(ui.mqr.get()).not.toBeChecked(); + expect(ui.standard.get()).toBeChecked(); + expect(ui.saveButton.get()).toBeInTheDocument(); + expect(ui.cancelButton.get()).toBeInTheDocument(); + expect(ui.saveWarning.get()).toBeInTheDocument(); + + await user.click(ui.cancelButton.get()); + expect(ui.mqr.get()).toBeChecked(); + expect(ui.standard.get()).not.toBeChecked(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + expect(ui.cancelButton.query()).not.toBeInTheDocument(); + expect(ui.saveWarning.query()).not.toBeInTheDocument(); + + await user.click(ui.standard.get()); + await user.click(ui.saveButton.get()); + expect(ui.mqr.get()).not.toBeChecked(); + expect(ui.standard.get()).toBeChecked(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + expect(ui.cancelButton.query()).not.toBeInTheDocument(); + expect(ui.saveWarning.query()).not.toBeInTheDocument(); +}); + +it('should be able to select mqr mode', async () => { + const user = userEvent.setup(); + settingServiceMock.set(SettingsKey.MQRMode, 'false'); + renderMode(); + + expect(await ui.standard.find()).toBeInTheDocument(); + expect(ui.mqr.get()).not.toBeChecked(); + expect(ui.standard.get()).toBeChecked(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + expect(ui.cancelButton.query()).not.toBeInTheDocument(); + expect(ui.saveWarning.query()).not.toBeInTheDocument(); + + await user.click(ui.mqr.get()); + expect(ui.mqr.get()).toBeChecked(); + expect(ui.standard.get()).not.toBeChecked(); + expect(ui.saveButton.get()).toBeInTheDocument(); + expect(ui.cancelButton.get()).toBeInTheDocument(); + expect(ui.saveWarning.get()).toBeInTheDocument(); + + await user.click(ui.cancelButton.get()); + expect(ui.mqr.get()).not.toBeChecked(); + expect(ui.standard.get()).toBeChecked(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + expect(ui.cancelButton.query()).not.toBeInTheDocument(); + expect(ui.saveWarning.query()).not.toBeInTheDocument(); + + await user.click(ui.mqr.get()); + await user.click(ui.saveButton.get()); + expect(ui.mqr.get()).toBeChecked(); + expect(ui.standard.get()).not.toBeChecked(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + expect(ui.cancelButton.query()).not.toBeInTheDocument(); + expect(ui.saveWarning.query()).not.toBeInTheDocument(); +}); + +function renderMode() { + return renderComponent(); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx index 34efb6e342c..004ff61a176 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx @@ -155,6 +155,14 @@ describe('Global Settings', () => { expect(await ui.generalComputeEngineHeading.find()).toBeInTheDocument(); }); + + it('can open mode and see custom implementation', async () => { + const user = userEvent.setup(); + renderSettingsApp(); + + await user.click(await ui.categoryLink('settings.mode.title').find()); + expect(byRole('radio', { name: /settings.mode.standard/ }).get()).toBeInTheDocument(); + }); }); describe('Project Settings', () => { diff --git a/server/sonar-web/src/main/js/apps/settings/constants.ts b/server/sonar-web/src/main/js/apps/settings/constants.ts index d873be017e8..fd7a647fe2e 100644 --- a/server/sonar-web/src/main/js/apps/settings/constants.ts +++ b/server/sonar-web/src/main/js/apps/settings/constants.ts @@ -29,6 +29,7 @@ export const LANGUAGES_CATEGORY = 'languages'; export const NEW_CODE_PERIOD_CATEGORY = 'new_code_period'; export const PULL_REQUEST_DECORATION_BINDING_CATEGORY = 'pull_request_decoration_binding'; export const EMAIL_NOTIFICATION_CATEGORY = 'email_notification'; +export const MODE_CATEGORY = 'mode'; export const CATEGORY_OVERRIDES: Dict = { abap: LANGUAGES_CATEGORY, diff --git a/server/sonar-web/src/main/js/helpers/doc-links.ts b/server/sonar-web/src/main/js/helpers/doc-links.ts index cce7a923191..e02e3a1fafe 100644 --- a/server/sonar-web/src/main/js/helpers/doc-links.ts +++ b/server/sonar-web/src/main/js/helpers/doc-links.ts @@ -67,6 +67,8 @@ export enum DocLink { MainBranchAnalysis = '/project-administration/maintaining-the-branches-of-your-project/', ManagingPortfolios = '/project-administration/managing-portfolios/', MetricDefinitions = '/user-guide/code-metrics/metrics-definition/', + ModeMQR = '/instance-administration/analysis-functions/instance-mode/mqr-mode', + ModeStandard = '/instance-administration/analysis-functions/instance-mode/standard-experience', Monorepos = '/project-administration/monorepos/', NewCodeDefinition = '/project-administration/clean-as-you-code-settings/defining-new-code/', NewCodeDefinitionOptions = '/project-administration/clean-as-you-code-settings/defining-new-code/#new-code-definition-options', diff --git a/server/sonar-web/src/main/js/queries/settings.ts b/server/sonar-web/src/main/js/queries/settings.ts index e9fb110dd44..bcb04f12948 100644 --- a/server/sonar-web/src/main/js/queries/settings.ts +++ b/server/sonar-web/src/main/js/queries/settings.ts @@ -27,7 +27,7 @@ import { setSimpleSettingValue, } from '../api/settings'; import { translate } from '../helpers/l10n'; -import { ExtendedSettingDefinition, SettingsKey } from '../types/settings'; +import { ExtendedSettingDefinition, SettingsKey, SettingValue } from '../types/settings'; import { createQueryHook } from './common'; import { invalidateAllMeasures } from './measures'; @@ -35,7 +35,7 @@ const SETTINGS_SAVE_SUCCESS_MESSAGE = translate( 'settings.authentication.form.settings.save_success', ); -type SettingValue = string | boolean | string[]; +type SettingFinalValue = string | boolean | string[]; export function useGetValuesQuery(keys: string[]) { return useQuery({ @@ -84,7 +84,7 @@ export function useSaveValuesMutation() { mutationFn: ( values: { definition: ExtendedSettingDefinition; - newValue?: SettingValue; + newValue?: SettingFinalValue; }[], ) => { return Promise.all( @@ -126,7 +126,7 @@ export function useSaveValueMutation() { }: { component?: string; definition: ExtendedSettingDefinition; - newValue: SettingValue; + newValue: SettingFinalValue; }) => { if (isDefaultValue(newValue, definition)) { return resetSettingValue({ keys: definition.key, component }); @@ -142,21 +142,32 @@ export function useSaveValueMutation() { }); } -export function useSaveSimpleValueMutation() { +export function useSaveSimpleValueMutation(updateCache = false) { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ key, value }: { key: string; value: string }) => { return setSimpleSettingValue({ key, value }); }, - onSuccess: (_, { key }) => { - queryClient.invalidateQueries({ queryKey: ['settings', 'details', key] }); + onSuccess: (_, { value, key }) => { + if (updateCache) { + queryClient.setQueryData(['settings', 'details', key], (oldData) => + oldData + ? { + ...oldData, + value: oldData.value !== undefined ? String(value) : undefined, + } + : oldData, + ); + } else { + queryClient.invalidateQueries({ queryKey: ['settings', 'details', key] }); + } queryClient.invalidateQueries({ queryKey: ['settings', 'values', [key]] }); addGlobalSuccessMessage(SETTINGS_SAVE_SUCCESS_MESSAGE); }, }); } -function isDefaultValue(value: SettingValue, definition: ExtendedSettingDefinition) { +function isDefaultValue(value: SettingFinalValue, definition: ExtendedSettingDefinition) { const defaultValue = definition.defaultValue ?? ''; if (definition.multiValues) { return defaultValue === (value as string[]).join(','); diff --git a/sonar-core/src/main/java/org/sonar/core/config/MQRModeProperties.java b/sonar-core/src/main/java/org/sonar/core/config/MQRModeProperties.java index 536aeae5cbb..5b7cfd12372 100644 --- a/sonar-core/src/main/java/org/sonar/core/config/MQRModeProperties.java +++ b/sonar-core/src/main/java/org/sonar/core/config/MQRModeProperties.java @@ -47,6 +47,7 @@ public final class MQRModeProperties { .type(PropertyType.BOOLEAN) .category(UI_MODE) .subCategory(UI_MODE_SUB_CATEGORY) + .hidden() .index(1) .build() ); diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 0d8dc819ad4..9c8e346cb22 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1813,6 +1813,19 @@ settings.pr_decoration.binding.form.gitlab.repository.help=The Project ID is a n settings.email_notification.category=Email Notification settings.email_notification.header=SMTP Configuration +# Mode +settings.mode.title=Mode +settings.mode.description.line1=There are two options to reflect the health of all the projects in this instance: {mqrLink} and {standardLink} +settings.mode.description.line2=Changing the mode will change how issues are categorized and ranked based on the results of the analysis. +settings.mode.standard.name=Standard Experience +settings.mode.mqr.name=Multi-Quality Rule (MQR) Mode +settings.mode.standard.description.line1=Encompasses the traditional use of rule types such as bugs, code smells, and vulnerabilities, with a single category and severity level for each rule. +settings.mode.standard.description.line2=This approach focuses on assigning severity to a rule and its issues based on the single software quality (for example, security, reliability or maintainability) it has the largest impact on. This is the rule categorization used in SonarQube 9.9 LTA and earlier. +settings.mode.mqr.description.line1=Aims to more accurately represent the impact software has on all software qualities. Very few issues impact only a single software quality. For instance, most vulnerabilities are also bugs. And vice versa. The MQR mode maps each rule to each of the qualities it impacts, with a separate severity rating for each quality. +settings.mode.mqr.description.line2=This approach focuses on ensuring the impact of an issue on all software qualities is clear, not just the most severe one. +settings.mode.save.warning=Save changes to see them reflected in your instance +settings.mode.save=Save the mode. The current mode will be switched to {isStandardMode, select, true {Standard Experience} other {Multi-Quality Rule Mode}} + property.category.announcement=Announcement property.category.general=General property.category.general.email=Email -- 2.39.5