diff options
author | Viktor Vorona <viktor.vorona@sonarsource.com> | 2024-10-17 18:03:21 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-10-19 20:02:32 +0000 |
commit | 691fe9681e59a01bca5e6079e74c2d3995ba3667 (patch) | |
tree | 955e37ed43ec0545f83ab9f6883f17a87bf43e6a /server/sonar-web | |
parent | a063c19e012fafa748fe8d3269482e758697c05b (diff) | |
download | sonarqube-691fe9681e59a01bca5e6079e74c2d3995ba3667.tar.gz sonarqube-691fe9681e59a01bca5e6079e74c2d3995ba3667.zip |
SONAR-23188 Custom design for the mode setting
Diffstat (limited to 'server/sonar-web')
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(','); |