aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorViktor Vorona <viktor.vorona@sonarsource.com>2024-10-17 18:03:21 +0200
committersonartech <sonartech@sonarsource.com>2024-10-19 20:02:32 +0000
commit691fe9681e59a01bca5e6079e74c2d3995ba3667 (patch)
tree955e37ed43ec0545f83ab9f6883f17a87bf43e6a /server/sonar-web
parenta063c19e012fafa748fe8d3269482e758697c05b (diff)
downloadsonarqube-691fe9681e59a01bca5e6079e74c2d3995ba3667.tar.gz
sonarqube-691fe9681e59a01bca5e6079e74c2d3995ba3667.zip
SONAR-23188 Custom design for the mode setting
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts9
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx14
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/Mode.tsx141
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/__tests__/Mode-it.tsx119
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/settings/constants.ts1
-rw-r--r--server/sonar-web/src/main/js/helpers/doc-links.ts2
-rw-r--r--server/sonar-web/src/main/js/queries/settings.ts27
8 files changed, 313 insertions, 8 deletions
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 <EmailNotification />;
}
+
+function getModeComponent() {
+ return <Mode />;
+}
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 (
+ <>
+ <Heading as="h2" className="sw-mb-4">
+ {intl.formatMessage({ id: 'settings.mode.title' })}
+ </Heading>
+ <Text>
+ <FormattedMessage
+ id="settings.mode.description.line1"
+ values={{
+ mqrLink: (
+ <DocumentationLink to={DocLink.ModeMQR}>
+ {intl.formatMessage({ id: 'settings.mode.mqr.name' })}
+ </DocumentationLink>
+ ),
+ standardLink: (
+ <DocumentationLink to={DocLink.ModeStandard}>
+ {intl.formatMessage({ id: 'settings.mode.standard.name' })}
+ </DocumentationLink>
+ ),
+ }}
+ />
+ </Text>
+ <br />
+ <br />
+ <Text as="div" className="sw-max-w-full sw-mb-6">
+ {intl.formatMessage({ id: 'settings.mode.description.line2' })}
+ </Text>
+ <Spinner isLoading={isLoading}>
+ <div className="sw-flex sw-gap-6">
+ <SelectionCard
+ disabled={isPending}
+ className="sw-basis-full"
+ onClick={() => setChangedMode(isStandardMode === false)}
+ selected={changedMode ? !isStandardMode : isStandardMode}
+ title={intl.formatMessage({ id: 'settings.mode.standard.name' })}
+ >
+ <div>
+ <Text>{intl.formatMessage({ id: 'settings.mode.standard.description.line1' })}</Text>
+ <br />
+ <br />
+ <Text>{intl.formatMessage({ id: 'settings.mode.standard.description.line2' })}</Text>
+ </div>
+ </SelectionCard>
+ <SelectionCard
+ disabled={isPending}
+ className="sw-basis-full"
+ onClick={() => setChangedMode(isStandardMode === true)}
+ selected={changedMode ? isStandardMode : !isStandardMode}
+ title={intl.formatMessage({ id: 'settings.mode.mqr.name' })}
+ >
+ <div>
+ <Text>{intl.formatMessage({ id: 'settings.mode.mqr.description.line1' })}</Text>
+ <br />
+ <br />
+ <Text>{intl.formatMessage({ id: 'settings.mode.mqr.description.line2' })}</Text>
+ </div>
+ </SelectionCard>
+ </div>
+ </Spinner>
+ <Text isSubdued as="div" className="sw-mt-6">
+ <FormattedMessage id="settings.key_x" values={{ '0': SettingsKey.MQRMode }} />
+ </Text>
+ {changedMode && (
+ <>
+ <ButtonGroup className="sw-mt-6">
+ <Button
+ isDisabled={isPending}
+ isLoading={isPending}
+ aria-label={intl.formatMessage(
+ { id: 'settings.mode.save' },
+ { isStandardMode: !isStandardMode },
+ )}
+ onClick={handleSave}
+ variety={ButtonVariety.Primary}
+ >
+ {intl.formatMessage({ id: 'save' })}
+ </Button>
+
+ <Button isDisabled={isPending} onClick={() => setChangedMode(false)}>
+ {intl.formatMessage({ id: 'cancel' })}
+ </Button>
+ </ButtonGroup>
+ <Text as="div" size={TextSize.Small} className="sw-mt-2">
+ {intl.formatMessage({ id: 'settings.mode.save.warning' })}
+ </Text>
+ </>
+ )}
+ </>
+ );
+}
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(<Mode />);
+}
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<string> = {
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<SettingValue>(['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(',');