From 68f1de76d0be69e9d52a5247bd0203a7625f5b7c Mon Sep 17 00:00:00 2001 From: stanislavh Date: Thu, 19 Oct 2023 17:07:11 +0200 Subject: [PATCH] SONAR-20742 Implement new SL promotion --- .../current-user/CurrentUserContext.ts | 9 +- .../BranchQualityGateConditions.tsx | 18 ++- .../__tests__/BranchQualityGate-it.tsx | 52 ++++---- .../pullRequests/PullRequestOverview.tsx | 8 +- .../overview/pullRequests/SonarLintAd.tsx | 111 ++++++++++++++++++ .../__tests__/PullRequestOverview-it.tsx | 31 +++++ .../hooks/__tests__/useLocalStorage-test.tsx | 79 +++++++++++++ .../src/main/js/hooks/useLocalStorage.ts | 48 ++++++++ .../resources/org/sonar/l10n/core.properties | 9 ++ 9 files changed, 335 insertions(+), 30 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx create mode 100644 server/sonar-web/src/main/js/hooks/__tests__/useLocalStorage-test.tsx create mode 100644 server/sonar-web/src/main/js/hooks/useLocalStorage.ts diff --git a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts index 56de6d94c66..ed6e75faa86 100644 --- a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts +++ b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts @@ -38,8 +38,15 @@ export const CurrentUserContext = React.createContext { renderBranchQualityGate(); // Maintainability rating condition - expect( - byRole('link', { - name: 'overview.failed_condition.x_requiredmetric_domain.Maintainability metric.type.rating A', - }).get(), - ).toBeInTheDocument(); + const maintainabilityRatingLink = byRole('link', { + name: 'overview.failed_condition.x_requiredmetric_domain.Maintainability metric.type.rating A', + }).get(); + expect(maintainabilityRatingLink).toBeInTheDocument(); + expect(maintainabilityRatingLink).toHaveAttribute( + 'href', + '/project/issues?resolved=false&types=CODE_SMELL&pullRequest=1001&sinceLeakPeriod=true&id=my-project', + ); // Security Hotspots rating condition - expect( - byRole('link', { - name: 'overview.failed_condition.x_requiredmetric_domain.Security Review metric.type.rating A', - }).get(), - ).toBeInTheDocument(); + const securityHotspotsRatingLink = byRole('link', { + name: 'overview.failed_condition.x_requiredmetric_domain.Security Review metric.type.rating A', + }).get(); + expect(securityHotspotsRatingLink).toBeInTheDocument(); + expect(securityHotspotsRatingLink).toHaveAttribute( + 'href', + '/security_hotspots?id=my-project&pullRequest=1001', + ); // New code smells - expect( - byRole('link', { - name: 'overview.failed_condition.x_required 5 Code Smells ≤ 1', - }).get(), - ).toBeInTheDocument(); + const codeSmellsLink = byRole('link', { + name: 'overview.failed_condition.x_required 5 Code Smells ≤ 1', + }).get(); + expect(codeSmellsLink).toBeInTheDocument(); + expect(codeSmellsLink).toHaveAttribute( + 'href', + '/project/issues?resolved=false&types=CODE_SMELL&pullRequest=1001&id=my-project', + ); // Conditions to cover - expect( - byRole('link', { - name: 'overview.failed_condition.x_required 5 Conditions to cover ≥ 10', - }).get(), - ).toBeInTheDocument(); + const conditionToCoverLink = byRole('link', { + name: 'overview.failed_condition.x_required 5 Conditions to cover ≥ 10', + }).get(); + expect(conditionToCoverLink).toBeInTheDocument(); + expect(conditionToCoverLink).toHaveAttribute( + 'href', + '/component_measures?id=my-project&metric=conditions_to_cover&pullRequest=1001&view=list', + ); expect(byLabelText('overview.quality_gate_x.overview.gate.ERROR').get()).toBeInTheDocument(); }); diff --git a/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx b/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx index 4f52f9ce8ef..9da9ac8c86b 100644 --- a/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx +++ b/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx @@ -32,9 +32,9 @@ import MeasuresCardPanel from '../branches/MeasuresCardPanel'; import BranchQualityGate from '../components/BranchQualityGate'; import IgnoredConditionWarning from '../components/IgnoredConditionWarning'; import MetaTopBar from '../components/MetaTopBar'; -import SonarLintPromotion from '../components/SonarLintPromotion'; import '../styles.css'; import { PR_METRICS, Status } from '../utils'; +import SonarLintAd from './SonarLintAd'; interface Props { branchLike: PullRequest; @@ -89,13 +89,13 @@ export default function PullRequestOverview(props: Props) { } const failedConditions = conditions - .filter((condition) => condition.level === 'ERROR') + .filter((condition) => condition.level === Status.ERROR) .map((c) => enhanceConditionWithMeasure(c, measures)) .filter(isDefined); return ( -
+
@@ -119,7 +119,7 @@ export default function PullRequestOverview(props: Props) { measures={measures} /> - +
diff --git a/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx b/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx new file mode 100644 index 00000000000..d73a9261e4b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx @@ -0,0 +1,111 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { + Card, + CheckIcon, + CloseIcon, + DiscreetInteractiveIcon, + LightLabel, + ListItem, + StandoutLink, + SubTitle, + SubnavigationFlowSeparator, +} from 'design-system'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { Status } from '../../../types/types'; +import { isLoggedIn } from '../../../types/users'; +import { Status as QGStatus } from '../utils'; + +interface Props { + status?: Status; +} + +const SONARLINT_PR_LS_KEY = 'sonarqube.pr_overview.show_sonarlint_promotion'; + +export default function SonarLintAd({ status }: Readonly) { + const intl = useIntl(); + const user = useCurrentUser(); + const [showSLPromotion, setSLPromotion] = useLocalStorage(SONARLINT_PR_LS_KEY, true); + + const onDismiss = React.useCallback(() => { + setSLPromotion(false); + }, [setSLPromotion]); + + if ( + !isLoggedIn(user) || + user.usingSonarLintConnectedMode || + status !== QGStatus.ERROR || + !showSLPromotion + ) { + return null; + } + + return ( + +
+ + {intl.formatMessage({ id: 'overview.sonarlint_ad.header' })} + + +
+
    + + + + + +
+ +
+ + {intl.formatMessage({ id: 'overview.sonarlint_ad.learn_more' })} + +
+
+ ); +} + +function TickLink({ className, message }: Readonly<{ className?: string; message: string }>) { + return ( + + + {message} + + ); +} + +const StyledSummaryCard = styled(Card)` + background-color: transparent; +`; diff --git a/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx b/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx index 7fc29a23c58..a9f02fd4f73 100644 --- a/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx +++ b/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { getQualityGateProjectStatus } from '../../../../api/quality-gates'; import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; @@ -185,6 +186,36 @@ it('should render correctly for a failed QG', async () => { ).toBeInTheDocument(); }); +it('renders SL promotion', async () => { + const user = userEvent.setup(); + jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ + status: 'ERROR', + conditions: [ + mockQualityGateProjectCondition({ + errorThreshold: '2.0', + metricKey: MetricKey.new_coverage, + periodIndex: 1, + }), + ], + caycStatus: CaycStatus.Compliant, + ignoredConditions: true, + }); + renderPullRequestOverview(); + + await waitFor(async () => + expect( + await byRole('heading', { name: 'overview.sonarlint_ad.header' }).find(), + ).toBeInTheDocument(), + ); + + // Close promotion + await user.click(byRole('button', { name: 'overview.sonarlint_ad.close_promotion' }).get()); + + expect( + byRole('heading', { name: 'overview.sonarlint_ad.header' }).query(), + ).not.toBeInTheDocument(); +}); + function renderPullRequestOverview( props: Partial> = {}, ) { diff --git a/server/sonar-web/src/main/js/hooks/__tests__/useLocalStorage-test.tsx b/server/sonar-web/src/main/js/hooks/__tests__/useLocalStorage-test.tsx new file mode 100644 index 00000000000..ea05b42342d --- /dev/null +++ b/server/sonar-web/src/main/js/hooks/__tests__/useLocalStorage-test.tsx @@ -0,0 +1,79 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { renderComponent } from '../../helpers/testReactTestingUtils'; +import { FCProps } from '../../types/misc'; +import useLocalStorage from '../useLocalStorage'; + +describe('useLocalStorage hook', () => { + it('gets/sets boolean value', async () => { + const user = userEvent.setup(); + renderLSComponent(); + + expect(screen.getByRole('button', { name: 'show' })).toBeInTheDocument(); + user.click(screen.getByRole('button', { name: 'show' })); + expect(await screen.findByText('text')).toBeInTheDocument(); + }); + + it('gets/sets string value', async () => { + const user = userEvent.setup(); + const props = { condition: (value: string) => value === 'ok', valueToSet: 'wow' }; + const { rerender } = renderLSComponent(props); + + expect(screen.getByRole('button', { name: 'show' })).toBeInTheDocument(); + user.click(screen.getByRole('button', { name: 'show' })); + expect(screen.queryByText('text')).not.toBeInTheDocument(); + + rerender(); + user.click(screen.getByRole('button', { name: 'show' })); + expect(await screen.findByText('text')).toBeInTheDocument(); + }); +}); + +function renderLSComponent(props: Partial> = {}) { + return renderComponent( + Boolean(value)} {...props} />, + ); +} + +function LSComponent({ + lsKey, + condition, + initialValue, + valueToSet, +}: Readonly<{ + lsKey: string; + condition: (value: boolean | string) => boolean; + valueToSet: boolean | string; + initialValue?: boolean | string; +}>) { + const [value, setValue] = useLocalStorage(lsKey, initialValue); + + return ( +
+ + {condition(value) && text} +
+ ); +} diff --git a/server/sonar-web/src/main/js/hooks/useLocalStorage.ts b/server/sonar-web/src/main/js/hooks/useLocalStorage.ts new file mode 100644 index 00000000000..17130c199c7 --- /dev/null +++ b/server/sonar-web/src/main/js/hooks/useLocalStorage.ts @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; +import { get, save } from '../helpers/storage'; + +export default function useLocalStorage(key: string, initialValue?: T) { + const lsValue = React.useCallback(() => { + const v = get(key); + try { + return JSON.parse(v as string); + } catch { + return v; + } + }, [key]); + + const [storedValue, setStoredValue] = React.useState(lsValue() ?? initialValue); + + const changeValue = React.useCallback( + (value: T) => { + save(key, JSON.stringify(value)); + setStoredValue(lsValue()); + }, + [key, lsValue], + ); + + React.useEffect(() => { + setStoredValue(lsValue() ?? initialValue); + }, [lsValue, initialValue]); + + return [storedValue, changeValue]; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 6d7bb4f79d8..98126eca58d 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3865,6 +3865,15 @@ overview.deprecated_profile=This Quality Profile uses {0} deprecated rules and s overview.deleted_profile={0} has been deleted since the last analysis. overview.link_to_x_profile_y=Go to {0} profile "{1}" +overview.sonarlint_ad.header=Catch issues before they fail your Quality Gate with our IDE extension, SonarLint +overview.sonarlint_ad.details_1=The power of Sonar analyzers directly as you type +overview.sonarlint_ad.details_2=No need to wait for your PR to pass all checks +overview.sonarlint_ad.details_3=Repair flagged issues in real-time with quick fixes +overview.sonarlint_ad.details_4=12 major IDE's supported (including key JetBrains and Microsoft IDE's +overview.sonarlint_ad.details_5=Free forever +overview.sonarlint_ad.learn_more=Learn more about SonarLint +overview.sonarlint_ad.close_promotion=Close SonarLint romotion + overview.badges.get_badge=Badges overview.badges.title=Get project badges overview.badges.description.TRK=Show the status of your project metrics on your README or website. Pick your style: -- 2.39.5