aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>2024-09-06 15:12:08 +0200
committersonartech <sonartech@sonarsource.com>2024-09-24 20:03:05 +0000
commitfa0ab44c86980c6ae26bb6e0832f1db02161fa58 (patch)
tree0ab48baceff408f0cb5606f44404180b818f6934 /server/sonar-web
parent502956b75fa6797e1a82d0d8ddb192e50683e92d (diff)
downloadsonarqube-fa0ab44c86980c6ae26bb6e0832f1db02161fa58.tar.gz
sonarqube-fa0ab44c86980c6ae26bb6e0832f1db02161fa58.zip
CODEFIX-32 Creating admin section for code fix suggestions
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx12
-rw-r--r--server/sonar-web/src/main/js/apps/issues/test-utils.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/DismissablePromotedSection.tsx93
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx55
-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/AllCategoriesList.tsx3
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx142
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx105
-rw-r--r--server/sonar-web/src/main/js/apps/settings/constants.ts1
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx9
-rw-r--r--server/sonar-web/src/main/js/helpers/urls.ts5
-rw-r--r--server/sonar-web/src/main/js/queries/fix-suggestions.tsx21
-rw-r--r--server/sonar-web/src/main/js/queries/settings.ts30
-rw-r--r--server/sonar-web/src/main/js/types/settings.ts1
15 files changed, 428 insertions, 69 deletions
diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
index 4082b39f2a3..292d906e2e1 100644
--- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
@@ -34,6 +34,7 @@ import {
issuesHandler,
renderIssueApp,
renderProjectIssuesApp,
+ settingsHandler,
sourcesHandler,
ui,
usersHandler,
@@ -68,6 +69,7 @@ beforeEach(() => {
componentsHandler.reset();
branchHandler.reset();
usersHandler.reset();
+ settingsHandler.reset();
usersHandler.users = [mockLoggedInUser() as unknown as RestUserDetailed];
window.scrollTo = jest.fn();
window.HTMLElement.prototype.scrollTo = jest.fn();
@@ -83,6 +85,7 @@ describe('issue app', () => {
});
it('should be able to trigger a fix when feature is available', async () => {
+ settingsHandler.set('sonar.ai.suggestions.enabled', 'true');
sourcesHandler.setSource(
range(0, 20)
.map((n) => `line: ${n}`)
@@ -96,7 +99,7 @@ describe('issue app', () => {
[Feature.BranchSupport, Feature.FixSuggestions],
);
- expect(await ui.getFixSuggestion.find(undefined, { timeout: 5000 })).toBeInTheDocument();
+ expect(await ui.getFixSuggestion.find(undefined, { timeout: 4000 })).toBeInTheDocument();
await user.click(ui.getFixSuggestion.get());
expect(await ui.suggestedExplanation.find()).toBeInTheDocument();
@@ -131,6 +134,7 @@ describe('issue app', () => {
});
it('should show error when no fix is available', async () => {
+ settingsHandler.set('sonar.ai.suggestions.enabled', 'true');
const user = userEvent.setup();
renderProjectIssuesApp(
`project/issues?issueStatuses=CONFIRMED&open=${ISSUE_101}&id=myproject`,
@@ -233,8 +237,6 @@ describe('issue app', () => {
await screen.findByRole('tab', { name: 'coding_rules.description_section.title.root_cause' }),
);
- await user.click(screen.getByRole('radio', { name: 'coding_rules.description_context.other' }));
-
expect(await screen.findByRole('heading', { name: 'CVE-2021-12345' })).toBeInTheDocument();
const rows = byRole('row').getAll(ui.cveTable.get());
@@ -259,10 +261,6 @@ describe('issue app', () => {
await screen.findByRole('tab', { name: 'coding_rules.description_section.title.root_cause' }),
);
- await user.click(
- await screen.findByRole('radio', { name: 'coding_rules.description_context.other' }),
- );
-
expect(await screen.findByRole('heading', { name: 'CVE-2021-12345' })).toBeInTheDocument();
const rows = byRole('row').getAll(ui.cveTable.get());
diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx
index aca69041755..434746fb0da 100644
--- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx
@@ -26,6 +26,7 @@ import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock';
import CveServiceMock from '../../api/mocks/CveServiceMock';
import FixIssueServiceMock from '../../api/mocks/FixIssueServiceMock';
import IssuesServiceMock from '../../api/mocks/IssuesServiceMock';
+import SettingsServiceMock from '../../api/mocks/SettingsServiceMock';
import SourcesServiceMock from '../../api/mocks/SourcesServiceMock';
import UsersServiceMock from '../../api/mocks/UsersServiceMock';
import { mockComponent } from '../../helpers/mocks/component';
@@ -49,6 +50,7 @@ export const componentsHandler = new ComponentsServiceMock();
export const sourcesHandler = new SourcesServiceMock();
export const branchHandler = new BranchesServiceMock();
export const fixIssueHanlder = new FixIssueServiceMock();
+export const settingsHandler = new SettingsServiceMock();
export const ui = {
loading: byText('issues.loading_issues'),
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
index aba16a8306b..7d83ed60083 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
@@ -43,12 +43,12 @@ import { Status } from '../utils';
import ActivityPanel from './ActivityPanel';
import BranchMetaTopBar from './BranchMetaTopBar';
import CaycPromotionGuide from './CaycPromotionGuide';
+import DismissablePromotedSection from './DismissablePromotedSection';
import FirstAnalysisNextStepsNotif from './FirstAnalysisNextStepsNotif';
import MeasuresPanelNoNewCode from './MeasuresPanelNoNewCode';
import NewCodeMeasuresPanel from './NewCodeMeasuresPanel';
import NoCodeWarning from './NoCodeWarning';
import OverallCodeMeasuresPanel from './OverallCodeMeasuresPanel';
-import PromotedSection from './PromotedSection';
import QGStatus from './QualityGateStatus';
import ReplayTourGuide from './ReplayTour';
import TabsPanel from './TabsPanel';
@@ -209,7 +209,7 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp
<CardSeparator />
{currentUser.isLoggedIn && hasNewCodeMeasures && (
- <PromotedSection
+ <DismissablePromotedSection
content={translate('overview.promoted_section.content')}
dismissed={dismissedTour ?? false}
onDismiss={dismissPromotedSection}
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/DismissablePromotedSection.tsx b/server/sonar-web/src/main/js/apps/overview/branches/DismissablePromotedSection.tsx
new file mode 100644
index 00000000000..45fda6b513e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/branches/DismissablePromotedSection.tsx
@@ -0,0 +1,93 @@
+/*
+ * 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 styled from '@emotion/styled';
+import { ButtonIcon, ButtonSize, ButtonVariety, IconX } from '@sonarsource/echoes-react';
+import { ButtonPrimary, ButtonSecondary, themeBorder, themeColor } from 'design-system';
+import React, { useState } from 'react';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+ content: string;
+ dismissed: boolean;
+ onDismiss: () => void;
+ onPrimaryButtonClick: () => void;
+ primaryButtonLabel: string;
+ secondaryButtonLabel: string;
+ title: string;
+}
+
+export default function DismissablePromotedSection({
+ content,
+ primaryButtonLabel,
+ secondaryButtonLabel,
+ title,
+ dismissed,
+ onDismiss,
+ onPrimaryButtonClick,
+}: Readonly<Props>) {
+ const [display, setDisplay] = useState(!dismissed);
+
+ const handlePrimaryButtonClick = () => {
+ setDisplay(false);
+ onPrimaryButtonClick();
+ };
+
+ const handleDismiss = () => {
+ setDisplay(false);
+ onDismiss();
+ };
+
+ if (!display) {
+ return null;
+ }
+
+ return (
+ <StyledWrapper className="sw-p-4 sw-pl-6 sw-my-6 sw-rounded-2">
+ <div className="sw-flex sw-justify-between sw-mb-2">
+ <StyledTitle className="sw-body-md-highlight">{title}</StyledTitle>
+
+ <ButtonIcon
+ Icon={IconX}
+ ariaLabel={translate('dismiss')}
+ onClick={handleDismiss}
+ size={ButtonSize.Medium}
+ variety={ButtonVariety.DefaultGhost}
+ />
+ </div>
+ <p className="sw-body-sm sw-mb-4">{content}</p>
+
+ <div>
+ <ButtonPrimary className="sw-mr-2" onClick={handlePrimaryButtonClick}>
+ {primaryButtonLabel}
+ </ButtonPrimary>
+ <ButtonSecondary onClick={handleDismiss}>{secondaryButtonLabel}</ButtonSecondary>
+ </div>
+ </StyledWrapper>
+ );
+}
+
+const StyledWrapper = styled.div`
+ background-color: ${themeColor('backgroundPromotedSection')};
+ border: ${themeBorder('default')};
+`;
+
+const StyledTitle = styled.p`
+ color: ${themeColor('primary')};
+`;
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx b/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx
index 745419f6407..ef111823780 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx
@@ -18,66 +18,21 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import styled from '@emotion/styled';
-import { ButtonIcon, ButtonSize, ButtonVariety, IconX } from '@sonarsource/echoes-react';
-import { ButtonPrimary, ButtonSecondary, themeBorder, themeColor } from 'design-system';
-import React, { useState } from 'react';
-import { translate } from '../../../helpers/l10n';
+import { themeBorder, themeColor } from 'design-system';
+import React from 'react';
interface Props {
- content: string;
- dismissed: boolean;
- onDismiss: () => void;
- onPrimaryButtonClick: () => void;
- primaryButtonLabel: string;
- secondaryButtonLabel: string;
+ content: React.ReactNode;
title: string;
}
-export default function PromotedSection({
- content,
- primaryButtonLabel,
- secondaryButtonLabel,
- title,
- dismissed,
- onDismiss,
- onPrimaryButtonClick,
-}: Readonly<Props>) {
- const [display, setDisplay] = useState(!dismissed);
-
- const handlePrimaryButtonClick = () => {
- setDisplay(false);
- onPrimaryButtonClick();
- };
-
- const handleDismiss = () => {
- setDisplay(false);
- onDismiss();
- };
-
- if (!display) {
- return null;
- }
-
+export default function PromotedSection({ content, title }: Readonly<Props>) {
return (
<StyledWrapper className="sw-p-4 sw-pl-6 sw-my-6 sw-rounded-2">
<div className="sw-flex sw-justify-between sw-mb-2">
<StyledTitle className="sw-typo-lg-semibold">{title}</StyledTitle>
-
- <ButtonIcon
- Icon={IconX}
- ariaLabel={translate('dismiss')}
- onClick={handleDismiss}
- size={ButtonSize.Medium}
- variety={ButtonVariety.DefaultGhost}
- />
- </div>
- <p className="sw-typo-default sw-mb-4">{content}</p>
- <div>
- <ButtonPrimary className="sw-mr-2" onClick={handlePrimaryButtonClick}>
- {primaryButtonLabel}
- </ButtonPrimary>
- <ButtonSecondary onClick={handleDismiss}>{secondaryButtonLabel}</ButtonSecondary>
</div>
+ <div className="sw-typo-default sw-mb-4">{content}</div>
</StyledWrapper>
);
}
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 0411fd4065d..7681bab2df4 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
@@ -25,12 +25,14 @@ import {
ALM_INTEGRATION_CATEGORY,
ANALYSIS_SCOPE_CATEGORY,
AUTHENTICATION_CATEGORY,
+ CODE_FIX_CATEGORY,
EMAIL_NOTIFICATION_CATEGORY,
LANGUAGES_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 NewCodeDefinition from './NewCodeDefinition';
import AlmIntegration from './almIntegration/AlmIntegration';
@@ -89,6 +91,14 @@ export const ADDITIONAL_CATEGORIES: AdditionalCategory[] = [
displayTab: true,
},
{
+ key: CODE_FIX_CATEGORY,
+ name: translate('property.category.codefix'),
+ renderComponent: getCodeFixComponent,
+ availableGlobally: true,
+ availableForProject: false,
+ displayTab: true,
+ },
+ {
key: PULL_REQUEST_DECORATION_BINDING_CATEGORY,
name: translate('settings.pr_decoration.binding.category'),
renderComponent: getPullRequestDecorationBindingComponent,
@@ -131,6 +141,10 @@ function getAlmIntegrationComponent(props: AdditionalCategoryComponentProps) {
return <AlmIntegration {...props} />;
}
+function getCodeFixComponent(props: AdditionalCategoryComponentProps) {
+ return <CodeFixAdmin {...props} />;
+}
+
function getAuthenticationComponent(props: AdditionalCategoryComponentProps) {
return <Authentication {...props} />;
}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx b/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx
index 158c5a9c7eb..f76239120d0 100644
--- a/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx
+++ b/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx
@@ -72,7 +72,8 @@ function CategoriesList(props: Readonly<CategoriesListProps>) {
return (
c.displayTab &&
availableForCurrentMenu &&
- (props.hasFeature(Feature.BranchSupport) || !c.requiresBranchSupport)
+ (props.hasFeature(Feature.BranchSupport) || !c.requiresBranchSupport) &&
+ (props.hasFeature(Feature.FixSuggestions) || c.key !== 'codefix')
);
}),
);
diff --git a/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx b/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx
new file mode 100644
index 00000000000..f2cc6a98353
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx
@@ -0,0 +1,142 @@
+/*
+ * 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, ButtonVariety, Checkbox, LinkStandalone } from '@sonarsource/echoes-react';
+import { BasicSeparator, Title } from 'design-system';
+import React, { useEffect } from 'react';
+import { FormattedMessage } from 'react-intl';
+import withAvailableFeatures, {
+ WithAvailableFeaturesProps,
+} from '../../../app/components/available-features/withAvailableFeatures';
+import { translate } from '../../../helpers/l10n';
+import { getAiCodeFixTermsOfServiceUrl } from '../../../helpers/urls';
+import { useRemoveCodeSuggestionsCache } from '../../../queries/fix-suggestions';
+import { useGetValueQuery, useSaveSimpleValueMutation } from '../../../queries/settings';
+import { Feature } from '../../../types/features';
+import { SettingsKey } from '../../../types/settings';
+import PromotedSection from '../../overview/branches/PromotedSection';
+
+interface Props extends WithAvailableFeaturesProps {}
+
+const CODE_FIX_SETTING_KEY = SettingsKey.CodeSuggestion;
+
+function CodeFixAdmin({ hasFeature }: Readonly<Props>) {
+ const { data: codeFixSetting } = useGetValueQuery({
+ key: CODE_FIX_SETTING_KEY,
+ });
+
+ const removeCodeSuggestionsCache = useRemoveCodeSuggestionsCache();
+
+ const { mutate: saveSetting } = useSaveSimpleValueMutation();
+
+ const isCodeFixEnabled = codeFixSetting?.value === 'true';
+
+ const [enableCodeFix, setEnableCodeFix] = React.useState(isCodeFixEnabled);
+ const [acceptedTerms, setAcceptedTerms] = React.useState(false);
+ const isValueChanged = enableCodeFix !== isCodeFixEnabled;
+
+ useEffect(() => {
+ setEnableCodeFix(isCodeFixEnabled);
+ }, [isCodeFixEnabled]);
+
+ const handleSave = () => {
+ saveSetting(
+ { key: CODE_FIX_SETTING_KEY, value: enableCodeFix ? 'true' : 'false' },
+ {
+ onSuccess: removeCodeSuggestionsCache,
+ },
+ );
+ };
+
+ const handleCancel = () => {
+ setEnableCodeFix(isCodeFixEnabled);
+ setAcceptedTerms(false);
+ };
+
+ if (!hasFeature(Feature.FixSuggestions)) {
+ return null;
+ }
+
+ return (
+ <div className="sw-flex">
+ <div className="sw-flex-1 sw-p-6">
+ <Title className="sw-heading-md sw-mb-6">{translate('property.codefix.admin.title')}</Title>
+ <PromotedSection
+ content={
+ <>
+ <p>{translate('property.codefix.admin.promoted_section.content1')}</p>
+ <p className="sw-mt-2">
+ {translate('property.codefix.admin.promoted_section.content2')}
+ </p>
+ </>
+ }
+ title={translate('property.codefix.admin.promoted_section.title')}
+ />
+ <p>{translate('property.codefix.admin.description')}</p>
+ <Checkbox
+ className="sw-mt-6"
+ label={translate('property.codefix.admin.checkbox.label')}
+ checked={Boolean(enableCodeFix)}
+ onCheck={() => setEnableCodeFix(!enableCodeFix)}
+ />
+ {isValueChanged && (
+ <div>
+ <BasicSeparator className="sw-mt-6" />
+ {enableCodeFix && (
+ <Checkbox
+ className="sw-mt-6"
+ label={
+ <FormattedMessage
+ id="property.codefix.admin.terms"
+ defaultMessage={translate('property.codefix.admin.acceptTerm.label')}
+ values={{
+ terms: (
+ <LinkStandalone to={getAiCodeFixTermsOfServiceUrl()}>
+ {translate('property.codefix.admin.acceptTerm.terms')}
+ </LinkStandalone>
+ ),
+ }}
+ />
+ }
+ checked={acceptedTerms}
+ onCheck={() => setAcceptedTerms(!acceptedTerms)}
+ />
+ )}
+ <div className="sw-mt-6">
+ <Button
+ variety={ButtonVariety.Primary}
+ isDisabled={!acceptedTerms && enableCodeFix}
+ onClick={() => {
+ handleSave();
+ }}
+ >
+ {translate('save')}
+ </Button>
+ <Button className="sw-ml-3" variety={ButtonVariety.Default} onClick={handleCancel}>
+ {translate('cancel')}
+ </Button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export default withAvailableFeatures(CodeFixAdmin);
diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx
new file mode 100644
index 00000000000..7767249f48c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx
@@ -0,0 +1,105 @@
+/*
+ * 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 { waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { uniq } from 'lodash';
+import * as React from 'react';
+import { byRole } from '~sonar-aligned/helpers/testSelector';
+import SettingsServiceMock, {
+ DEFAULT_DEFINITIONS_MOCK,
+} from '../../../../api/mocks/SettingsServiceMock';
+import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
+import { mockComponent } from '../../../../helpers/mocks/component';
+import { definitions } from '../../../../helpers/mocks/definitions-list';
+import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import { Feature } from '../../../../types/features';
+import { AdditionalCategoryComponentProps } from '../AdditionalCategories';
+import CodeFixAdmin from '../CodeFixAdmin';
+
+let settingServiceMock: SettingsServiceMock;
+
+beforeAll(() => {
+ settingServiceMock = new SettingsServiceMock();
+ settingServiceMock.setDefinitions(definitions);
+});
+
+afterEach(() => {
+ settingServiceMock.reset();
+});
+
+const ui = {
+ codeFixTitle: byRole('heading', { name: 'property.codefix.admin.title' }),
+ changeCodeFixCheckbox: byRole('checkbox', { name: 'property.codefix.admin.checkbox.label' }),
+ acceptTermCheckbox: byRole('checkbox', {
+ name: 'property.codefix.admin.terms property.codefix.admin.acceptTerm.terms open_in_new_tab',
+ }),
+ saveButton: byRole('button', { name: 'save' }),
+};
+
+it('should be able to enable the code fix feature', async () => {
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ expect(await ui.codeFixTitle.find()).toBeInTheDocument();
+ expect(ui.changeCodeFixCheckbox.get()).not.toBeChecked();
+
+ await user.click(ui.changeCodeFixCheckbox.get());
+ expect(ui.acceptTermCheckbox.get()).toBeInTheDocument();
+ expect(ui.saveButton.get()).toBeDisabled();
+
+ await user.click(ui.acceptTermCheckbox.get());
+ expect(ui.saveButton.get()).toBeEnabled();
+
+ await user.click(ui.saveButton.get());
+ expect(ui.changeCodeFixCheckbox.get()).toBeChecked();
+});
+
+it('should be able to disable the code fix feature', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'true');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await waitFor(() => {
+ expect(ui.changeCodeFixCheckbox.get()).toBeChecked();
+ });
+
+ await user.click(ui.changeCodeFixCheckbox.get());
+ expect(await ui.saveButton.find()).toBeInTheDocument();
+ await user.click(await ui.saveButton.find());
+ expect(ui.changeCodeFixCheckbox.get()).not.toBeChecked();
+});
+
+function renderCodeFixAdmin(
+ overrides: Partial<AdditionalCategoryComponentProps> = {},
+ features?: Feature[],
+) {
+ const props = {
+ definitions: DEFAULT_DEFINITIONS_MOCK,
+ categories: uniq(DEFAULT_DEFINITIONS_MOCK.map((d) => d.category)),
+ selectedCategory: 'general',
+ component: mockComponent(),
+ ...overrides,
+ };
+ return renderComponent(
+ <AvailableFeaturesContext.Provider value={features ?? [Feature.FixSuggestions]}>
+ <CodeFixAdmin {...props} />
+ </AvailableFeaturesContext.Provider>,
+ );
+}
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 4ab46992bbf..bff256271b4 100644
--- a/server/sonar-web/src/main/js/apps/settings/constants.ts
+++ b/server/sonar-web/src/main/js/apps/settings/constants.ts
@@ -22,6 +22,7 @@ import { ExtendedSettingDefinition } from '../../types/settings';
import { Dict } from '../../types/types';
export const ALM_INTEGRATION_CATEGORY = 'almintegration';
+export const CODE_FIX_CATEGORY = 'codefix';
export const AUTHENTICATION_CATEGORY = 'authentication';
export const ANALYSIS_SCOPE_CATEGORY = 'exclusions';
export const LANGUAGES_CATEGORY = 'languages';
diff --git a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx
index 15feceeea3b..48dd46cf989 100644
--- a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx
+++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx
@@ -410,9 +410,9 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
aria-labelledby={`tab-${selectedTab.key}`}
id={`tabpanel-${selectedTab.key}`}
>
- {
- // Preserve tabs state by always rendering all of them. Only hide them when not selected
- tabs.map((tab) => (
+ {tabs
+ .filter((t) => t.key === selectedTab.key)
+ .map((tab) => (
<div
className={classNames({
'sw-hidden': tab.key !== selectedTab.key,
@@ -423,8 +423,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
{tab.content}
</TabSelectorContext.Provider>
</div>
- ))
- }
+ ))}
</div>
</div>
)}
diff --git a/server/sonar-web/src/main/js/helpers/urls.ts b/server/sonar-web/src/main/js/helpers/urls.ts
index 19ce4896dff..4c6895e50a9 100644
--- a/server/sonar-web/src/main/js/helpers/urls.ts
+++ b/server/sonar-web/src/main/js/helpers/urls.ts
@@ -54,6 +54,7 @@ type CodeScopeType = CodeScope.Overall | CodeScope.New;
export type Query = Location['query'];
const PROJECT_BASE_URL = '/dashboard';
+const SONARSOURCE_COM_URL = 'https://www.sonarsource.com';
export function getComponentOverviewUrl(
componentKey: string,
@@ -414,3 +415,7 @@ export function convertToTo(link: string | Location) {
function linkIsLocation(link: string | Location): link is Location {
return (link as Location).query !== undefined;
}
+
+export function getAiCodeFixTermsOfServiceUrl(): string {
+ return `${SONARSOURCE_COM_URL}/legal/ai-codefix-terms/`;
+}
diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx
index a235a9101b6..6459cdd701a 100644
--- a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx
+++ b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx
@@ -24,8 +24,10 @@ import { getFixSuggestionsIssues, getSuggestions } from '../api/fix-suggestions'
import { useAvailableFeatures } from '../app/components/available-features/withAvailableFeatures';
import { CurrentUserContext } from '../app/components/current-user/CurrentUserContext';
import { Feature } from '../types/features';
+import { SettingsKey } from '../types/settings';
import { Issue } from '../types/types';
import { isLoggedIn } from '../types/users';
+import { useGetValueQuery } from './settings';
import { useRawSourceQuery } from './sources';
const UNKNOWN = -1;
@@ -142,16 +144,33 @@ export function useGetFixSuggestionsIssuesQuery(issue: Issue) {
const { currentUser } = useContext(CurrentUserContext);
const { hasFeature } = useAvailableFeatures();
+ const { data: codeFixSetting } = useGetValueQuery(
+ {
+ key: SettingsKey.CodeSuggestion,
+ },
+ { staleTime: Infinity },
+ );
+
+ const isCodeFixEnabled = codeFixSetting?.value === 'true';
+
return useQuery({
queryKey: ['code-suggestions', 'issues', 'details', issue.key],
queryFn: () =>
getFixSuggestionsIssues({
issueId: issue.key,
}),
- enabled: hasFeature(Feature.FixSuggestions) && isLoggedIn(currentUser),
+ enabled: hasFeature(Feature.FixSuggestions) && isLoggedIn(currentUser) && isCodeFixEnabled,
+ staleTime: Infinity,
});
}
+export function useRemoveCodeSuggestionsCache() {
+ const queryClient = useQueryClient();
+ return () => {
+ queryClient.removeQueries({ queryKey: ['code-suggestions'] });
+ };
+}
+
export function withUseGetFixSuggestionsIssues<P extends { issue: Issue }>(
Component: React.ComponentType<
Omit<P, 'aiSuggestionAvailable'> & { aiSuggestionAvailable: boolean }
diff --git a/server/sonar-web/src/main/js/queries/settings.ts b/server/sonar-web/src/main/js/queries/settings.ts
index 5d1cb2f2d6c..0f25e391be9 100644
--- a/server/sonar-web/src/main/js/queries/settings.ts
+++ b/server/sonar-web/src/main/js/queries/settings.ts
@@ -19,12 +19,22 @@
*/
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { addGlobalSuccessMessage } from 'design-system';
-import { getValue, getValues, resetSettingValue, setSettingValue } from '../api/settings';
+import {
+ getValue,
+ getValues,
+ resetSettingValue,
+ setSettingValue,
+ setSimpleSettingValue,
+} from '../api/settings';
import { translate } from '../helpers/l10n';
import { ExtendedSettingDefinition } from '../types/settings';
import { createQueryHook } from './common';
import { invalidateAllMeasures } from './measures';
+const SETTINGS_SAVE_SUCCESS_MESSAGE = translate(
+ 'settings.authentication.form.settings.save_success',
+);
+
type SettingValue = string | boolean | string[];
export function useGetValuesQuery(keys: string[]) {
@@ -104,7 +114,7 @@ export function useSaveValuesMutation() {
queryClient.invalidateQueries({ queryKey: ['settings', 'details', key] });
});
queryClient.invalidateQueries({ queryKey: ['settings', 'values'] });
- addGlobalSuccessMessage(translate('settings.authentication.form.settings.save_success'));
+ addGlobalSuccessMessage(SETTINGS_SAVE_SUCCESS_MESSAGE);
}
},
});
@@ -131,7 +141,21 @@ export function useSaveValueMutation() {
queryClient.invalidateQueries({ queryKey: ['settings', 'details', definition.key] });
queryClient.invalidateQueries({ queryKey: ['settings', 'values'] });
invalidateAllMeasures(queryClient);
- addGlobalSuccessMessage(translate('settings.authentication.form.settings.save_success'));
+ addGlobalSuccessMessage(SETTINGS_SAVE_SUCCESS_MESSAGE);
+ },
+ });
+}
+
+export function useSaveSimpleValueMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ key, value }: { key: string; value: string }) => {
+ return setSimpleSettingValue({ key, value });
+ },
+ onSuccess: (_, { key }) => {
+ queryClient.invalidateQueries({ queryKey: ['settings', 'details', key] });
+ queryClient.invalidateQueries({ queryKey: ['settings', 'values', [key]] });
+ addGlobalSuccessMessage(SETTINGS_SAVE_SUCCESS_MESSAGE);
},
});
}
diff --git a/server/sonar-web/src/main/js/types/settings.ts b/server/sonar-web/src/main/js/types/settings.ts
index 16f5083bd54..ce12260c3c3 100644
--- a/server/sonar-web/src/main/js/types/settings.ts
+++ b/server/sonar-web/src/main/js/types/settings.ts
@@ -29,6 +29,7 @@ export const enum SettingsKey {
TokenMaxAllowedLifetime = 'sonar.auth.token.max.allowed.lifetime',
QPAdminCanDisableInheritedRules = 'sonar.qualityProfiles.allowDisableInheritedRules',
LegacyMode = 'sonar.legacy.ratings.mode.enabled',
+ CodeSuggestion = 'sonar.ai.suggestions.enabled',
}
export enum GlobalSettingKeys {