diff options
author | Damien Urruty <damien.urruty@sonarsource.com> | 2024-11-21 11:04:22 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-11-29 20:03:06 +0000 |
commit | 0e670b6152c075f8ada586e5202ce8b89d59a0ab (patch) | |
tree | 126b6cbdc974dacec0cda64fef2e6d3b45d1993e /server | |
parent | 63b65fbfaacd481915707c79f40754f272739eb2 (diff) | |
download | sonarqube-0e670b6152c075f8ada586e5202ce8b89d59a0ab.tar.gz sonarqube-0e670b6152c075f8ada586e5202ce8b89d59a0ab.zip |
CODEFIX-209 Adapt the display based on the current subscription
Co-Authored-By: Dam <64742703+damien-urruty-sonarsource@users.noreply.github.com>
Co-Authored-By: Serhat Yenican <104850907+serhat-yenican-sonarsource@users.noreply.github.com>
Diffstat (limited to 'server')
15 files changed, 661 insertions, 340 deletions
diff --git a/server/sonar-web/src/main/js/api/fix-suggestions.ts b/server/sonar-web/src/main/js/api/fix-suggestions.ts index a337e8be7aa..d3c374e3fe5 100644 --- a/server/sonar-web/src/main/js/api/fix-suggestions.ts +++ b/server/sonar-web/src/main/js/api/fix-suggestions.ts @@ -37,8 +37,12 @@ export type SuggestionServiceStatus = | 'CONNECTION_ERROR' | 'SERVICE_ERROR'; -export interface SuggestionServiceStatusCheckResponse { +export type SubscriptionType = 'EARLY_ACCESS' | 'PAID' | 'NOT_PAID'; + +export interface ServiceInfo { + isEnabled?: boolean; status: SuggestionServiceStatus; + subscriptionType?: SubscriptionType; } export interface UpdateFeatureEnablementParams { @@ -57,8 +61,8 @@ export function getFixSuggestionsIssues(data: FixParam): Promise<AiIssue> { return axiosToCatch.get(`/api/v2/fix-suggestions/issues/${data.issueId}`); } -export function checkSuggestionServiceStatus(): Promise<SuggestionServiceStatusCheckResponse> { - return axiosToCatch.post(`/api/v2/fix-suggestions/service-status-checks`); +export function getFixSuggestionServiceInfo(): Promise<ServiceInfo> { + return axiosToCatch.get(`/api/v2/fix-suggestions/service-info`); } export function updateFeatureEnablement( diff --git a/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts index f46b2c36ac3..38d0f8ea62c 100644 --- a/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts @@ -20,12 +20,13 @@ import { cloneDeep } from 'lodash'; import { - checkSuggestionServiceStatus, FixParam, + getFixSuggestionServiceInfo, getFixSuggestionsIssues, getSuggestions, + ServiceInfo, + SubscriptionType, SuggestionServiceStatus, - SuggestionServiceStatusCheckResponse, updateFeatureEnablement, UpdateFeatureEnablementParams, } from '../fix-suggestions'; @@ -33,7 +34,11 @@ import { ISSUE_101, ISSUE_1101 } from './data/ids'; jest.mock('../fix-suggestions'); -export type MockSuggestionServiceStatus = SuggestionServiceStatus | 'WTF' | undefined; +export type MockFixSuggestionServiceInfo = { + isEnabled?: boolean; + status: SuggestionServiceStatus | 'WTF'; + subscriptionType?: SubscriptionType | 'WTF'; +}; export default class FixSuggestionsServiceMock { fixSuggestion = { @@ -50,12 +55,15 @@ export default class FixSuggestionsServiceMock { ], }; - serviceStatus: MockSuggestionServiceStatus = 'SUCCESS'; + serviceInfo: MockFixSuggestionServiceInfo | undefined = { + status: 'SUCCESS', + subscriptionType: 'PAID', + }; constructor() { jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion); jest.mocked(getFixSuggestionsIssues).mockImplementation(this.handleGetFixSuggestionsIssues); - jest.mocked(checkSuggestionServiceStatus).mockImplementation(this.handleCheckService); + jest.mocked(getFixSuggestionServiceInfo).mockImplementation(this.handleGetServiceInfo); jest.mocked(updateFeatureEnablement).mockImplementation(this.handleUpdateFeatureEnablement); } @@ -73,9 +81,9 @@ export default class FixSuggestionsServiceMock { return this.reply(this.fixSuggestion); }; - handleCheckService = () => { - if (this.serviceStatus) { - return this.reply({ status: this.serviceStatus } as SuggestionServiceStatusCheckResponse); + handleGetServiceInfo = () => { + if (this.serviceInfo) { + return this.reply(this.serviceInfo as ServiceInfo); } return Promise.reject({ error: { msg: 'Error' } }); }; @@ -92,7 +100,7 @@ export default class FixSuggestionsServiceMock { }); } - setServiceStatus(status: MockSuggestionServiceStatus) { - this.serviceStatus = status; + setServiceInfo(info: MockFixSuggestionServiceInfo | undefined) { + this.serviceInfo = info; } } diff --git a/server/sonar-web/src/main/js/app/components/GlobalFooter.tsx b/server/sonar-web/src/main/js/app/components/GlobalFooter.tsx index 786421988b2..cd6b1af6d72 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalFooter.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalFooter.tsx @@ -24,7 +24,7 @@ import { useIntl } from 'react-intl'; import { FlagMessage, LAYOUT_VIEWPORT_MIN_WIDTH, themeBorder, themeColor } from '~design-system'; import InstanceMessage from '../../components/common/InstanceMessage'; import AppVersionStatus from '../../components/shared/AppVersionStatus'; -import { DocLink } from '../../helpers/doc-links'; +import { COMMUNITY_FORUM_URL, DocLink } from '../../helpers/doc-links'; import { useDocUrl } from '../../helpers/docs'; import { getEdition } from '../../helpers/editions'; import GlobalFooterBranding from './GlobalFooterBranding'; @@ -82,10 +82,7 @@ export default function GlobalFooter({ hideLoggedInInfo }: Readonly<GlobalFooter </li> <li> - <LinkStandalone - highlight={LinkHighlight.CurrentColor} - to="https://community.sonarsource.com/c/help/sq" - > + <LinkStandalone highlight={LinkHighlight.CurrentColor} to={COMMUNITY_FORUM_URL}> {intl.formatMessage({ id: 'footer.community' })} </LinkStandalone> </li> diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueDetails.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueDetails.tsx index 0179cd64c9a..a2c4d81e68b 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssueDetails.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssueDetails.tsx @@ -31,7 +31,7 @@ import { themeColor, } from '~design-system'; import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; -import { IssueSuggestionCodeTab } from '../../../components/rules/IssueSuggestionCodeTab'; +import { AiCodeFixTab } from '../../../components/rules/AiCodeFixTab'; import IssueTabViewer from '../../../components/rules/IssueTabViewer'; import { fillBranchLike } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; @@ -179,7 +179,7 @@ export default function IssueDetails({ /> } suggestionTabContent={ - <IssueSuggestionCodeTab + <AiCodeFixTab branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)} issue={openIssue} language={openRuleDetails.lang} 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 49cbc4e5357..a0a3137ac60 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 @@ -24,11 +24,20 @@ import { themeBorder, themeColor } from '~design-system'; interface Props { content: React.ReactNode; + image?: React.ReactNode; title: string; } -export default function PromotedSection({ content, title }: Readonly<Props>) { - return ( +export default function PromotedSection({ content, image, title }: Readonly<Props>) { + return image ? ( + <StyledWrapper className="sw-flex sw-items-center sw-p-4 sw-gap-8 sw-pl-6 sw-my-6 sw-rounded-2"> + {image} + <div className="sw-flex-col sw-mb-2"> + <StyledTitle className="sw-typo-lg-semibold sw-mb-4">{title}</StyledTitle> + <div className="sw-typo-default">{content}</div> + </div> + </StyledWrapper> + ) : ( <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> 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 ffd88b6893b..457141e79de 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 @@ -33,14 +33,14 @@ import { NEW_CODE_PERIOD_CATEGORY, PULL_REQUEST_DECORATION_BINDING_CATEGORY, } from '../constants'; -import AiCodeFixAdmin from './AiCodeFixAdmin'; +import AiCodeFixAdmin from './ai-codefix/AiCodeFixAdminCategory'; +import AlmIntegration from './almIntegration/AlmIntegration'; import { AnalysisScope } from './AnalysisScope'; +import Authentication from './authentication/Authentication'; +import EmailNotification from './email-notification/EmailNotification'; import Languages from './Languages'; import { Mode } from './Mode'; import NewCodeDefinition from './NewCodeDefinition'; -import AlmIntegration from './almIntegration/AlmIntegration'; -import Authentication from './authentication/Authentication'; -import EmailNotification from './email-notification/EmailNotification'; import PullRequestDecorationBinding from './pullRequestDecorationBinding/PRDecorationBinding'; export interface AdditionalCategoryComponentProps { diff --git a/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/AiCodeFixAdminCategory.tsx b/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/AiCodeFixAdminCategory.tsx new file mode 100644 index 00000000000..084d678db9c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/AiCodeFixAdminCategory.tsx @@ -0,0 +1,297 @@ +/* + * 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 { + Button, + ButtonGroup, + Heading, + IconError, + LinkStandalone, + Spinner, + Text, +} from '@sonarsource/echoes-react'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { OverviewQGPassedIcon, themeColor, UnorderedList } from '~design-system'; +import { throwGlobalError } from '~sonar-aligned/helpers/error'; +import { ServiceInfo } from '../../../../api/fix-suggestions'; +import withAvailableFeatures, { + WithAvailableFeaturesProps, +} from '../../../../app/components/available-features/withAvailableFeatures'; +import DocumentationLink from '../../../../components/common/DocumentationLink'; +import { LockIllustration } from '../../../../design-system/components/icons/LockIllustration'; +import { COMMUNITY_FORUM_URL, DocLink } from '../../../../helpers/doc-links'; +import { translate } from '../../../../helpers/l10n'; +import { useGetServiceInfoQuery } from '../../../../queries/fix-suggestions'; +import { Feature } from '../../../../types/features'; +import PromotedSection from '../../../overview/branches/PromotedSection'; +import AiCodeFixEnablementForm from './AiCodeFixEnablementForm'; + +interface Props extends WithAvailableFeaturesProps {} + +function AiCodeFixAdminCategory({ hasFeature }: Readonly<Props>) { + const { data, error, isPending, isError, refetch: refreshServiceInfo } = useGetServiceInfoQuery(); + + const retry = () => refreshServiceInfo().catch(throwGlobalError); + + if (!hasFeature(Feature.FixSuggestions)) { + return null; + } + + if (isPending) { + return <SubscriptionCheckPendingMessage />; + } + + if (isError) { + return ( + <ErrorView + onRetry={retry} + message={`${translate('property.aicodefix.admin.serviceInfo.result.requestError')} ${error?.status ?? 'No status'}`} + /> + ); + } + + if (!data) { + return ( + <ErrorView + onRetry={retry} + message={translate('property.aicodefix.admin.serviceInfo.empty.response.label')} + /> + ); + } + + return <ServiceInfoCheckValidResponseView response={data} onRetry={retry} />; +} + +function SubscriptionCheckPendingMessage() { + return ( + <div className="sw-p-8"> + <Spinner label={translate('property.aicodefix.admin.serviceInfo.spinner.label')} /> + </div> + ); +} + +function ServiceInfoCheckValidResponseView({ + response, + onRetry, +}: Readonly<{ onRetry: Function; response: ServiceInfo }>) { + switch (response?.status) { + case 'SUCCESS': + return <ServiceInfoCheckSuccessResponseView onRetry={onRetry} response={response} />; + case 'TIMEOUT': + case 'CONNECTION_ERROR': + return ( + <ErrorView + message={translate('property.aicodefix.admin.serviceInfo.result.unresponsive.message')} + onRetry={onRetry} + > + <div className="sw-flex-col"> + <p className="sw-mt-4"> + <ErrorLabel + text={translate( + 'property.aicodefix.admin.serviceInfo.result.unresponsive.causes.title', + )} + /> + </p> + <UnorderedList className="sw-ml-8" ticks> + <ErrorListItem className="sw-mb-2"> + <ErrorLabel + text={translate( + 'property.aicodefix.admin.serviceInfo.result.unresponsive.causes.1', + )} + /> + <p> + <DocumentationLink shouldOpenInNewTab to={DocLink.AiCodeFixEnabling}> + {translate('property.aicodefix.admin.serviceInfo.learnMore')} + </DocumentationLink> + </p> + </ErrorListItem> + <ErrorListItem> + <ErrorLabel + text={translate( + 'property.aicodefix.admin.serviceInfo.result.unresponsive.causes.2', + )} + /> + </ErrorListItem> + </UnorderedList> + </div> + </ErrorView> + ); + case 'UNAUTHORIZED': + return <ServiceInfoCheckUnauthorizedResponseView onRetry={onRetry} response={response} />; + case 'SERVICE_ERROR': + return ( + <ErrorView + onRetry={onRetry} + message={translate('property.aicodefix.admin.serviceInfo.result.serviceError')} + /> + ); + default: + return ( + <ErrorView + onRetry={onRetry} + message={`${translate('property.aicodefix.admin.serviceInfo.result.unknown')} ${response?.status ?? 'no status returned from the service'}`} + /> + ); + } +} + +function ServiceInfoCheckSuccessResponseView({ + onRetry, + response, +}: Readonly<{ onRetry: Function; response: ServiceInfo }>) { + switch (response.subscriptionType) { + case 'EARLY_ACCESS': + return <AiCodeFixEnablementForm isEarlyAccess />; + case 'PAID': + return <AiCodeFixEnablementForm />; + default: + return ( + <ErrorView + onRetry={onRetry} + message={translate('property.aicodefix.admin.serviceInfo.unexpected.response.label')} + /> + ); + } +} + +function ServiceInfoCheckUnauthorizedResponseView({ + onRetry, + response, +}: Readonly<{ onRetry: Function; response: ServiceInfo }>) { + if (response.subscriptionType === 'NOT_PAID') { + return <AiCodeFixPromotionMessage />; + } + + if (response.isEnabled != null && !response.isEnabled) { + return <FeatureNotAvailableMessage />; + } + + return ( + <ErrorView + onRetry={onRetry} + message={translate('property.aicodefix.admin.serviceInfo.result.unauthorized')} + /> + ); +} + +interface ErrorViewProps { + children?: React.ReactNode; + message: string; + onRetry: Function; +} + +function ErrorView({ children, message, onRetry }: Readonly<ErrorViewProps>) { + return ( + <div className="sw-flex sw-flex-col sw-gap-4 sw-items-start sw-max-w-abs-350 sw-p-6"> + <Heading as="h2" hasMarginBottom> + {translate('property.aicodefix.admin.serviceInfo.result.error.title')} + </Heading> + <div className="sw-flex"> + <IconError className="sw-mr-1" color="echoes-color-icon-danger" /> + <div className="sw-flex-col"> + <ErrorLabel text={message} /> + {children} + </div> + </div> + <Button onClick={() => onRetry()}> + {translate('property.aicodefix.admin.serviceInfo.result.error.retry.action')} + </Button> + <p> + <FormattedMessage + defaultMessage={translate( + 'property.aicodefix.admin.serviceInfo.result.error.retry.message', + )} + id="aicodefix.admin.serviceInfo.result.error.retry.message" + values={{ + link: ( + <LinkStandalone shouldOpenInNewTab to={COMMUNITY_FORUM_URL}> + {translate('property.aicodefix.admin.serviceInfo.result.error.retry.get_help')} + </LinkStandalone> + ), + }} + /> + </p> + </div> + ); +} + +function ErrorLabel({ text }: Readonly<TextProps>) { + return <Text colorOverride="echoes-color-text-danger">{text}</Text>; +} + +const ErrorListItem = styled.li` + ::marker { + color: ${themeColor('errorText')}; + } +`; + +interface TextProps { + /** The text to display inside the component */ + text: string; +} + +function FeatureNotAvailableMessage() { + return ( + <div className="sw-flex sw-flex-col sw-gap-2 sw-items-center sw-py-64"> + <LockIllustration /> + <Text as="b" className="sw-text-center"> + {translate('property.aicodefix.admin.disabled')} + </Text> + </div> + ); +} + +function AiCodeFixPromotionMessage() { + return ( + <div> + <Heading as="h2" hasMarginBottom> + {translate('property.aicodefix.admin.promotion.title')} + </Heading> + <PromotedSection + content={ + <MaxWidthDiv> + <p className="sw-pb-4">{translate('property.aicodefix.admin.promotion.content')}</p> + <ButtonGroup> + <LinkStandalone + shouldOpenInNewTab + to="mailto:contact@sonarsource.com?subject=Sonar%20AI%20CodeFix%20-%20Request%20for%20information" + > + {translate('property.aicodefix.admin.promotion.contact')} + </LinkStandalone> + <DocumentationLink shouldOpenInNewTab to={DocLink.AiCodeFixEnabling}> + {translate('property.aicodefix.admin.promotion.checkDocumentation')} + </DocumentationLink> + </ButtonGroup> + </MaxWidthDiv> + } + title={translate('property.aicodefix.admin.promotion.subtitle')} + image={<OverviewQGPassedIcon width={84} height={84} />} + /> + </div> + ); +} + +const MaxWidthDiv = styled.div` + max-width: var(--echoes-sizes-typography-max-width-default); +`; + +export default withAvailableFeatures(AiCodeFixAdminCategory); diff --git a/server/sonar-web/src/main/js/apps/settings/components/AiCodeFixAdmin.tsx b/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/AiCodeFixEnablementForm.tsx index f5a9526bf24..d66a36e6c8d 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/AiCodeFixAdmin.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/AiCodeFixEnablementForm.tsx @@ -18,61 +18,44 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import styled from '@emotion/styled'; import { Button, ButtonVariety, Checkbox, Heading, - IconCheckCircle, - IconError, IconInfo, Link, RadioButtonGroup, - Spinner, Text, } from '@sonarsource/echoes-react'; -import { MutationStatus } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; -import { - BasicSeparator, - HighlightedSection, - Note, - themeColor, - UnorderedList, -} from '~design-system'; +import { Note } from '~design-system'; import { throwGlobalError } from '~sonar-aligned/helpers/error'; -import { searchProjects } from '../../../api/components'; -import { SuggestionServiceStatusCheckResponse } from '../../../api/fix-suggestions'; -import withAvailableFeatures, { - WithAvailableFeaturesProps, -} from '../../../app/components/available-features/withAvailableFeatures'; -import DocumentationLink from '../../../components/common/DocumentationLink'; +import { searchProjects } from '../../../../api/components'; +import withAvailableFeatures from '../../../../app/components/available-features/withAvailableFeatures'; import SelectList, { SelectListFilter, SelectListSearchParams, -} from '../../../components/controls/SelectList'; -import { DocLink } from '../../../helpers/doc-links'; -import { translate } from '../../../helpers/l10n'; -import { getAiCodeFixTermsOfServiceUrl } from '../../../helpers/urls'; +} from '../../../../components/controls/SelectList'; +import { translate } from '../../../../helpers/l10n'; +import { getAiCodeFixTermsOfServiceUrl } from '../../../../helpers/urls'; import { - useCheckServiceMutation, useRemoveCodeSuggestionsCache, useUpdateFeatureEnablementMutation, -} from '../../../queries/fix-suggestions'; -import { useGetValueQuery } from '../../../queries/settings'; -import { Feature } from '../../../types/features'; -import { AiCodeFixFeatureEnablement } from '../../../types/fix-suggestions'; -import { SettingsKey } from '../../../types/settings'; -import PromotedSection from '../../overview/branches/PromotedSection'; - -interface Props extends WithAvailableFeaturesProps {} +} from '../../../../queries/fix-suggestions'; +import { useGetValueQuery } from '../../../../queries/settings'; +import { AiCodeFixFeatureEnablement } from '../../../../types/fix-suggestions'; +import { SettingsKey } from '../../../../types/settings'; +import PromotedSection from '../../../overview/branches/PromotedSection'; const AI_CODE_FIX_SETTING_KEY = SettingsKey.CodeSuggestion; -function AiCodeFixAdmin({ hasFeature }: Readonly<Props>) { +interface AiCodeFixEnablementFormProps { + isEarlyAccess?: boolean; +} + +function AiCodeFixEnablementForm({ isEarlyAccess }: Readonly<AiCodeFixEnablementFormProps>) { const { data: aiCodeFixSetting } = useGetValueQuery({ key: AI_CODE_FIX_SETTING_KEY, }); @@ -86,14 +69,6 @@ function AiCodeFixAdmin({ hasFeature }: Readonly<Props>) { ); const [currentAiCodeFixEnablement, setCurrentAiCodeFixEnablement] = React.useState(savedAiCodeFixEnablement); - const { - mutate: checkService, - isIdle, - isPending: isServiceCheckPending, - status, - error, - data, - } = useCheckServiceMutation(); const { mutate: updateFeatureEnablement } = useUpdateFeatureEnablementMutation(); const [changedProjects, setChangedProjects] = React.useState<Map<string, boolean>>(new Map()); @@ -146,10 +121,6 @@ function AiCodeFixAdmin({ hasFeature }: Readonly<Props>) { } }; - if (!hasFeature(Feature.FixSuggestions)) { - return null; - } - const renderProjectElement = (projectKey: string): React.ReactNode => { const project = currentTabItems.find((project) => project.key === projectKey); return ( @@ -251,17 +222,19 @@ function AiCodeFixAdmin({ hasFeature }: Readonly<Props>) { <Heading as="h2" hasMarginBottom> {translate('property.aicodefix.admin.title')} </Heading> - <PromotedSection - content={ - <> - <p>{translate('property.aicodefix.admin.promoted_section.content1')}</p> - <p className="sw-mt-2"> - {translate('property.aicodefix.admin.promoted_section.content2')} - </p> - </> - } - title={translate('property.aicodefix.admin.promoted_section.title')} - /> + {isEarlyAccess && ( + <PromotedSection + content={ + <> + <p>{translate('property.aicodefix.admin.early_access.content1')}</p> + <p className="sw-mt-2"> + {translate('property.aicodefix.admin.early_access.content2')} + </p> + </> + } + title={translate('property.aicodefix.admin.early_access.title')} + /> + )} <p>{translate('property.aicodefix.admin.description')}</p> <Checkbox className="sw-my-6" @@ -357,32 +330,6 @@ function AiCodeFixAdmin({ hasFeature }: Readonly<Props>) { </div> </div> </div> - <div className="sw-flex-col sw-w-abs-600 sw-p-6"> - <HighlightedSection className="sw-items-start"> - <Heading as="h3" hasMarginBottom> - {translate('property.aicodefix.admin.serviceCheck.title')} - </Heading> - <p>{translate('property.aicodefix.admin.serviceCheck.description1')}</p> - <DocumentationLink to={DocLink.AiCodeFixEnabling}> - {translate('property.aicodefix.admin.serviceCheck.learnMore')} - </DocumentationLink> - <p>{translate('property.aicodefix.admin.serviceCheck.description2')}</p> - <Button - className="sw-mt-4" - variety={ButtonVariety.Default} - onClick={() => checkService()} - isDisabled={isServiceCheckPending} - > - {translate('property.aicodefix.admin.serviceCheck.action')} - </Button> - {!isIdle && ( - <div> - <BasicSeparator className="sw-my-4" /> - <ServiceCheckResultView data={data} error={error} status={status} /> - </div> - )} - </HighlightedSection> - </div> </div> ); } @@ -405,122 +352,4 @@ interface ProjectItem { selected: boolean; } -interface ServiceCheckResultViewProps { - data: SuggestionServiceStatusCheckResponse | undefined; - error: AxiosError | null; - status: MutationStatus; -} - -function ServiceCheckResultView({ data, error, status }: Readonly<ServiceCheckResultViewProps>) { - switch (status) { - case 'pending': - return <Spinner label={translate('property.aicodefix.admin.serviceCheck.spinner.label')} />; - case 'error': - return ( - <ErrorMessage - text={`${translate('property.aicodefix.admin.serviceCheck.result.requestError')} ${error?.status ?? 'No status'}`} - /> - ); - case 'success': - return ServiceCheckValidResponseView(data); - } - // normally unreachable - throw Error(`Unexpected response from the service status check, received ${status}`); -} - -function ServiceCheckValidResponseView(data: SuggestionServiceStatusCheckResponse | undefined) { - switch (data?.status) { - case 'SUCCESS': - return ( - <SuccessMessage text={translate('property.aicodefix.admin.serviceCheck.result.success')} /> - ); - case 'TIMEOUT': - case 'CONNECTION_ERROR': - return ( - <div className="sw-flex"> - <IconError className="sw-mr-1" color="echoes-color-icon-danger" /> - <div className="sw-flex-col"> - <ErrorLabel - text={translate('property.aicodefix.admin.serviceCheck.result.unresponsive.message')} - /> - <p className="sw-mt-4"> - <ErrorLabel - text={translate( - 'property.aicodefix.admin.serviceCheck.result.unresponsive.causes.title', - )} - /> - </p> - <UnorderedList className="sw-ml-8" ticks> - <ErrorListItem className="sw-mb-2"> - <ErrorLabel - text={translate( - 'property.aicodefix.admin.serviceCheck.result.unresponsive.causes.1', - )} - /> - </ErrorListItem> - <ErrorListItem> - <ErrorLabel - text={translate( - 'property.aicodefix.admin.serviceCheck.result.unresponsive.causes.2', - )} - /> - </ErrorListItem> - </UnorderedList> - </div> - </div> - ); - case 'UNAUTHORIZED': - return ( - <ErrorMessage - text={translate('property.aicodefix.admin.serviceCheck.result.unauthorized')} - /> - ); - case 'SERVICE_ERROR': - return ( - <ErrorMessage - text={translate('property.aicodefix.admin.serviceCheck.result.serviceError')} - /> - ); - default: - return ( - <ErrorMessage - text={`${translate('property.aicodefix.admin.serviceCheck.result.unknown')} ${data?.status ?? 'no status returned from the service'}`} - /> - ); - } -} - -function ErrorMessage({ text }: Readonly<TextProps>) { - return ( - <div className="sw-flex"> - <IconError className="sw-mr-1" color="echoes-color-icon-danger" /> - <ErrorLabel text={text} /> - </div> - ); -} - -function ErrorLabel({ text }: Readonly<TextProps>) { - return <Text colorOverride="echoes-color-text-danger">{text}</Text>; -} - -function SuccessMessage({ text }: Readonly<TextProps>) { - return ( - <div className="sw-flex"> - <IconCheckCircle className="sw-mr-1" color="echoes-color-icon-success" /> - <Text colorOverride="echoes-color-text-success">{text}</Text> - </div> - ); -} - -const ErrorListItem = styled.li` - ::marker { - color: ${themeColor('errorText')}; - } -`; - -interface TextProps { - /** The text to display inside the component */ - text: string; -} - -export default withAvailableFeatures(AiCodeFixAdmin); +export default withAvailableFeatures(AiCodeFixEnablementForm); diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/AiCodeFixAdmin-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/__tests__/AiCodeFixAdmin-it.tsx index e5c576631da..d62342c030a 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/AiCodeFixAdmin-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/ai-codefix/__tests__/AiCodeFixAdmin-it.tsx @@ -22,28 +22,28 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { uniq } from 'lodash'; import { byRole, byText } from '~sonar-aligned/helpers/testSelector'; -import ComponentsServiceMock from '../../../../api/mocks/ComponentsServiceMock'; -import FixSuggestionsServiceMock from '../../../../api/mocks/FixSuggestionsServiceMock'; +import ComponentsServiceMock from '../../../../../api/mocks/ComponentsServiceMock'; +import FixSuggestionsServiceMock from '../../../../../api/mocks/FixSuggestionsServiceMock'; import SettingsServiceMock, { DEFAULT_DEFINITIONS_MOCK, -} from '../../../../api/mocks/SettingsServiceMock'; -import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; -import { mockComponent, mockComponentRaw } 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 AiCodeFixAdmin from '../AiCodeFixAdmin'; +} from '../../../../../api/mocks/SettingsServiceMock'; +import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext'; +import { mockComponent, mockComponentRaw } 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 AiCodeFixAdmin from '../AiCodeFixAdminCategory'; let settingServiceMock: SettingsServiceMock; let componentsServiceMock: ComponentsServiceMock; -let fixIssueServiceMock: FixSuggestionsServiceMock; +let fixSuggestionsServiceMock: FixSuggestionsServiceMock; beforeAll(() => { settingServiceMock = new SettingsServiceMock(); settingServiceMock.setDefinitions(definitions); componentsServiceMock = new ComponentsServiceMock(); - fixIssueServiceMock = new FixSuggestionsServiceMock(); + fixSuggestionsServiceMock = new FixSuggestionsServiceMock(); }); afterEach(() => { @@ -57,8 +57,8 @@ const ui = { }), saveButton: byRole('button', { name: 'save' }), cancelButton: byRole('button', { name: 'cancel' }), - checkServiceStatusButton: byRole('button', { - name: 'property.aicodefix.admin.serviceCheck.action', + retryButton: byRole('button', { + name: 'property.aicodefix.admin.serviceInfo.result.error.retry.action', }), allProjectsEnabledRadio: byRole('radio', { name: 'property.aicodefix.admin.enable.all.projects.label', @@ -71,7 +71,137 @@ const ui = { allTab: byRole('radio', { name: 'all' }), }; +it('should display the enablement form when having a paid subscription', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); + renderCodeFixAdmin(); + + expect(await screen.findByText('property.aicodefix.admin.description')).toBeInTheDocument(); +}); + +it('should display the enablement form and disclaimer when in early access', async () => { + fixSuggestionsServiceMock.setServiceInfo({ + isEnabled: true, + status: 'SUCCESS', + subscriptionType: 'EARLY_ACCESS', + }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.early_access.content1'), + ).toBeInTheDocument(); +}); + +it('should display a disabled message when in early access and disabled by the customer', async () => { + fixSuggestionsServiceMock.setServiceInfo({ + isEnabled: false, + status: 'UNAUTHORIZED', + subscriptionType: 'EARLY_ACCESS', + }); + renderCodeFixAdmin(); + + expect(await screen.findByText('property.aicodefix.admin.disabled')).toBeInTheDocument(); +}); + +it('should promote the feature when not paid', async () => { + fixSuggestionsServiceMock.setServiceInfo({ + status: 'UNAUTHORIZED', + subscriptionType: 'NOT_PAID', + }); + renderCodeFixAdmin(); + + expect(await screen.findByText('property.aicodefix.admin.promotion.content')).toBeInTheDocument(); +}); + +it('should display an error message when the subscription is unknown', async () => { + fixSuggestionsServiceMock.setServiceInfo({ + status: 'SUCCESS', + subscriptionType: 'WTF', + }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.unexpected.response.label'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the service is not responsive', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'TIMEOUT' }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.unresponsive.message'), + ).toBeInTheDocument(); +}); + +it('should display an error message when there is a connection error with the service', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'CONNECTION_ERROR' }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.unresponsive.message'), + ).toBeInTheDocument(); +}); + +it('should propose to retry when an error occurs', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'CONNECTION_ERROR' }); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.unresponsive.message'), + ).toBeInTheDocument(); + expect(ui.retryButton.get()).toBeEnabled(); + + fixSuggestionsServiceMock.setServiceInfo({ + isEnabled: true, + status: 'SUCCESS', + subscriptionType: 'EARLY_ACCESS', + }); + await user.click(ui.retryButton.get()); + + expect( + await screen.findByText('property.aicodefix.admin.early_access.content1'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the current instance is unauthorized', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'UNAUTHORIZED' }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.unauthorized'), + ).toBeInTheDocument(); +}); + +it('should display an error message when an error happens at service level', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SERVICE_ERROR' }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.serviceError'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the service answers with an unknown status', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'WTF' }); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.unknown WTF'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the backend answers with an error', async () => { + fixSuggestionsServiceMock.setServiceInfo(undefined); + renderCodeFixAdmin(); + + expect( + await screen.findByText('property.aicodefix.admin.serviceInfo.result.requestError No status'), + ).toBeInTheDocument(); +}); + it('should by default propose enabling for all projects when enabling the feature', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); settingServiceMock.set('sonar.ai.suggestions.enabled', 'DISABLED'); const user = userEvent.setup(); renderCodeFixAdmin(); @@ -84,6 +214,7 @@ it('should by default propose enabling for all projects when enabling the featur }); it('should be able to enable the code fix feature for all projects', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); settingServiceMock.set('sonar.ai.suggestions.enabled', 'DISABLED'); const user = userEvent.setup(); renderCodeFixAdmin(); @@ -103,13 +234,16 @@ it('should be able to enable the code fix feature for all projects', async () => }); it('should be able to enable the code fix feature for some projects', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); settingServiceMock.set('sonar.ai.suggestions.enabled', 'DISABLED'); const project = mockComponentRaw({ isAiCodeFixEnabled: false }); componentsServiceMock.registerProject(project); const user = userEvent.setup(); renderCodeFixAdmin(); - expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked(); + await waitFor(() => { + expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked(); + }); await user.click(ui.enableAiCodeFixCheckbox.get()); expect(ui.someProjectsEnabledRadio.get()).toBeEnabled(); @@ -133,6 +267,7 @@ it('should be able to enable the code fix feature for some projects', async () = }); it('should be able to disable the feature for a single project', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); settingServiceMock.set('sonar.ai.suggestions.enabled', 'ENABLED_FOR_SOME_PROJECTS'); const project = mockComponentRaw({ isAiCodeFixEnabled: true }); componentsServiceMock.registerProject(project); @@ -158,6 +293,7 @@ it('should be able to disable the feature for a single project', async () => { }); it('should be able to disable the code fix feature', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); settingServiceMock.set('sonar.ai.suggestions.enabled', 'ENABLED_FOR_ALL_PROJECTS'); const user = userEvent.setup(); renderCodeFixAdmin(); @@ -173,6 +309,7 @@ it('should be able to disable the code fix feature', async () => { }); it('should be able to reset the form when canceling', async () => { + fixSuggestionsServiceMock.setServiceInfo({ status: 'SUCCESS', subscriptionType: 'PAID' }); settingServiceMock.set('sonar.ai.suggestions.enabled', 'ENABLED_FOR_ALL_PROJECTS'); componentsServiceMock.registerComponent(mockComponent()); const user = userEvent.setup(); @@ -189,90 +326,6 @@ it('should be able to reset the form when canceling', async () => { expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked(); }); -it('should display a success message when the service status can be successfully checked', async () => { - fixIssueServiceMock.setServiceStatus('SUCCESS'); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.success'), - ).toBeInTheDocument(); -}); - -it('should display an error message when the service is not responsive', async () => { - fixIssueServiceMock.setServiceStatus('TIMEOUT'); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.unresponsive.message'), - ).toBeInTheDocument(); -}); - -it('should display an error message when there is a connection error with the service', async () => { - fixIssueServiceMock.setServiceStatus('CONNECTION_ERROR'); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.unresponsive.message'), - ).toBeInTheDocument(); -}); - -it('should display an error message when the current instance is unauthorized', async () => { - fixIssueServiceMock.setServiceStatus('UNAUTHORIZED'); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.unauthorized'), - ).toBeInTheDocument(); -}); - -it('should display an error message when an error happens at service level', async () => { - fixIssueServiceMock.setServiceStatus('SERVICE_ERROR'); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.serviceError'), - ).toBeInTheDocument(); -}); - -it('should display an error message when the service answers with an unknown status', async () => { - fixIssueServiceMock.setServiceStatus('WTF'); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.unknown WTF'), - ).toBeInTheDocument(); -}); - -it('should display an error message when the backend answers with an error', async () => { - fixIssueServiceMock.setServiceStatus(undefined); - const user = userEvent.setup(); - renderCodeFixAdmin(); - - await user.click(ui.checkServiceStatusButton.get()); - - expect( - await screen.findByText('property.aicodefix.admin.serviceCheck.result.requestError No status'), - ).toBeInTheDocument(); -}); - function renderCodeFixAdmin( overrides: Partial<AdditionalCategoryComponentProps> = {}, features?: Feature[], diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx b/server/sonar-web/src/main/js/components/rules/AiCodeFixTab.tsx index 55525b6b8e7..40085e539ca 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx +++ b/server/sonar-web/src/main/js/components/rules/AiCodeFixTab.tsx @@ -34,7 +34,7 @@ interface Props { language?: string; } -export function IssueSuggestionCodeTab({ branchLike, issue, language }: Readonly<Props>) { +export function AiCodeFixTab({ branchLike, issue, language }: Readonly<Props>) { const prefetchSuggestion = usePrefetchSuggestion(issue.key); const { isPending, isLoading, isError, refetch } = useUnifiedSuggestionsQuery(issue, false); const { isError: isIssueRawError } = useRawSourceQuery({ diff --git a/server/sonar-web/src/main/js/design-system/components/icons/LockIllustration.tsx b/server/sonar-web/src/main/js/design-system/components/icons/LockIllustration.tsx new file mode 100644 index 00000000000..e1e8fc57153 --- /dev/null +++ b/server/sonar-web/src/main/js/design-system/components/icons/LockIllustration.tsx @@ -0,0 +1,55 @@ +/* + * 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. + */ + +export function LockIllustration() { + return ( + <svg + width="168" + height="168" + viewBox="0 0 168 168" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <g clipPath="url(#clip0_21804_11)"> + <path d="M137 154.5V93C131 141.4 87.8333 154.333 67 154.5H137Z" fill="#F4F6FF" /> + <path d="M31 63L31 158H39L39 63H31Z" fill="#BDC6FF" /> + <path + d="M38.4996 160.125C34.8538 160.125 31.7548 158.849 29.2028 156.297C26.6507 153.745 25.3746 150.646 25.3746 147V73.4999C25.3746 69.8541 26.6507 66.7551 29.2028 64.203C31.7548 61.6509 34.8538 60.3749 38.4996 60.3749H51.6246V42.8749C51.6246 33.8332 54.7601 26.177 61.0309 19.9061C67.3017 13.6353 74.958 10.4999 83.9996 10.4999C93.0413 10.4999 100.698 13.6353 106.968 19.9061C113.239 26.177 116.375 33.8332 116.375 42.8749V60.3749H129.5C133.145 60.3749 136.244 61.6509 138.797 64.203C141.349 66.7551 142.625 69.8541 142.625 73.4999V147C142.625 150.646 141.349 153.745 138.797 156.297C136.244 158.849 133.145 160.125 129.5 160.125H38.4996ZM38.4996 154H129.5C131.541 154 133.218 153.344 134.531 152.031C135.843 150.719 136.5 149.042 136.5 147V73.4999C136.5 71.4582 135.843 69.7811 134.531 68.4686C133.218 67.1561 131.541 66.4999 129.5 66.4999H38.4996C36.458 66.4999 34.7809 67.1561 33.4684 68.4686C32.1559 69.7811 31.4996 71.4582 31.4996 73.4999V147C31.4996 149.042 32.1559 150.719 33.4684 152.031C34.7809 153.344 36.458 154 38.4996 154ZM57.7496 60.3749H110.25V42.8749C110.25 35.5832 107.698 29.3853 102.593 24.2811C97.4892 19.177 91.2913 16.6249 83.9996 16.6249C76.708 16.6249 70.5101 19.177 65.4059 24.2811C60.3017 29.3853 57.7496 35.5832 57.7496 42.8749V60.3749Z" + fill="#6A7590" + /> + <path + d="M92.4215 114.234C90.1611 116.495 87.3538 117.625 83.9996 117.625C80.6455 117.625 77.8382 116.495 75.5778 114.234C73.3173 111.974 72.1871 109.167 72.1871 105.813C72.1871 102.458 73.3173 99.651 75.5778 97.3906C77.8382 95.1302 80.6455 94 83.9996 94C87.3538 94 90.1611 95.1302 92.4215 97.3906C94.6819 99.651 95.8121 102.458 95.8121 105.813C95.8121 109.167 94.6819 111.974 92.4215 114.234Z" + fill="#7B87D9" + /> + <path d="M78 111.563H90V126.563H78V111.563Z" fill="#7B87D9" /> + <path + d="M67 63.5V42.8C67 38.0963 68.6275 34.1903 71.944 30.907C75.2616 27.6225 79.2214 26 84 26C88.7786 26 92.7384 27.6225 96.056 30.907C99.3725 34.1903 101 38.0963 101 42.8V63.5H67Z" + stroke="#6A7590" + strokeWidth="6" + /> + </g> + <defs> + <clipPath id="clip0_21804_11"> + <rect width="168" height="168" fill="white" /> + </clipPath> + </defs> + </svg> + ); +} diff --git a/server/sonar-web/src/main/js/design-system/components/icons/MailIcon.tsx b/server/sonar-web/src/main/js/design-system/components/icons/MailIcon.tsx new file mode 100644 index 00000000000..0998a3c1948 --- /dev/null +++ b/server/sonar-web/src/main/js/design-system/components/icons/MailIcon.tsx @@ -0,0 +1,51 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import { themeColor } from '~design-system'; + +export function MailIcon() { + const theme = useTheme(); + + return ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g id="mail"> + <mask + id="mask0_6548_2965" + style={{ maskType: 'alpha' }} + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="16" + height="16" + > + <rect id="Bounding box" width="16" height="16" fill="#D9D9D9" /> + </mask> + <g mask="url(#mask0_6548_2965)"> + <path + id="mail_2" + d="M2.66659 13.3332C2.29992 13.3332 1.98603 13.2026 1.72492 12.9415C1.46381 12.6804 1.33325 12.3665 1.33325 11.9998V3.99984C1.33325 3.63317 1.46381 3.31928 1.72492 3.05817C1.98603 2.79706 2.29992 2.6665 2.66659 2.6665H13.3333C13.6999 2.6665 14.0138 2.79706 14.2749 3.05817C14.536 3.31928 14.6666 3.63317 14.6666 3.99984V11.9998C14.6666 12.3665 14.536 12.6804 14.2749 12.9415C14.0138 13.2026 13.6999 13.3332 13.3333 13.3332H2.66659ZM7.99992 8.6665L2.66659 5.33317V11.9998H13.3333V5.33317L7.99992 8.6665ZM7.99992 7.33317L13.3333 3.99984H2.66659L7.99992 7.33317ZM2.66659 5.33317V3.99984V11.9998V5.33317Z" + fill={themeColor('currentColor')({ theme })} + /> + </g> + </g> + </svg> + ); +} diff --git a/server/sonar-web/src/main/js/design-system/components/icons/OverviewQGPassedIcon.tsx b/server/sonar-web/src/main/js/design-system/components/icons/OverviewQGPassedIcon.tsx index 86c6324373e..a0d861b45c3 100644 --- a/server/sonar-web/src/main/js/design-system/components/icons/OverviewQGPassedIcon.tsx +++ b/server/sonar-web/src/main/js/design-system/components/icons/OverviewQGPassedIcon.tsx @@ -19,19 +19,34 @@ */ import { useTheme } from '@emotion/react'; -import { themeColor } from '../../helpers/theme'; +import { themeColor } from '../../helpers'; -export function OverviewQGPassedIcon({ className }: { className?: string }) { +interface OverviewQGPassedIconProps { + className?: string; + height?: number; + width?: number; +} + +const DEFAULT_WIDTH = 154; +const DEFAULT_HEIGHT = 136; + +export function OverviewQGPassedIcon({ + className, + height, + width, +}: Readonly<OverviewQGPassedIconProps>) { const theme = useTheme(); + const actualWidth = width ?? DEFAULT_WIDTH; + const actualHeight = height ?? DEFAULT_HEIGHT; return ( <svg className={className} fill="none" - height="136" + height={actualHeight} role="img" - viewBox="0 0 154 136" - width="154" + viewBox={`0 0 ${DEFAULT_WIDTH} ${DEFAULT_HEIGHT}`} + width={actualWidth} xmlns="http://www.w3.org/2000/svg" > <path 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 d8047d15e73..a846d31cf84 100644 --- a/server/sonar-web/src/main/js/helpers/doc-links.ts +++ b/server/sonar-web/src/main/js/helpers/doc-links.ts @@ -20,6 +20,8 @@ import { AlmKeys } from '../types/alm-settings'; +export const COMMUNITY_FORUM_URL = 'https://community.sonarsource.com/c/help/sq'; + export const DOC_URL = 'https://docs.sonarsource.com/sonarqube/latest'; export enum DocLink { 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 8edcb03f044..4a125384e82 100644 --- a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx +++ b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx @@ -23,10 +23,10 @@ import { AxiosError } from 'axios'; import { some } from 'lodash'; import React, { useContext } from 'react'; import { - checkSuggestionServiceStatus, + getFixSuggestionServiceInfo, getFixSuggestionsIssues, getSuggestions, - SuggestionServiceStatusCheckResponse, + ServiceInfo, updateFeatureEnablement, } from '../api/fix-suggestions'; import { useAvailableFeatures } from '../app/components/available-features/withAvailableFeatures'; @@ -184,14 +184,15 @@ export function withUseGetFixSuggestionsIssues<P extends { issue: Issue }>( }; } -export function useUpdateFeatureEnablementMutation() { - return useMutation({ - mutationFn: updateFeatureEnablement, +export function useGetServiceInfoQuery() { + return useQuery<ServiceInfo, AxiosError>({ + queryKey: ['fix-suggestions', 'service-info'], + queryFn: getFixSuggestionServiceInfo, }); } -export function useCheckServiceMutation() { - return useMutation<SuggestionServiceStatusCheckResponse, AxiosError>({ - mutationFn: checkSuggestionServiceStatus, +export function useUpdateFeatureEnablementMutation() { + return useMutation({ + mutationFn: updateFeatureEnablement, }); } |