diff options
author | Revanshu Paliwal <revanshu.paliwal@sonarsource.com> | 2024-09-06 15:12:08 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-09-24 20:03:05 +0000 |
commit | fa0ab44c86980c6ae26bb6e0832f1db02161fa58 (patch) | |
tree | 0ab48baceff408f0cb5606f44404180b818f6934 /server/sonar-web | |
parent | 502956b75fa6797e1a82d0d8ddb192e50683e92d (diff) | |
download | sonarqube-fa0ab44c86980c6ae26bb6e0832f1db02161fa58.tar.gz sonarqube-fa0ab44c86980c6ae26bb6e0832f1db02161fa58.zip |
CODEFIX-32 Creating admin section for code fix suggestions
Diffstat (limited to 'server/sonar-web')
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 { |