From 2d81345e2528677c578a00f73871fd472b903f36 Mon Sep 17 00:00:00 2001 From: Philippe Perrin Date: Wed, 1 Nov 2023 11:31:03 +0100 Subject: [PATCH] MMF-3429 SonarWay smooth transition (#9791) Co-authored-by: Andrey Luiz Co-authored-by: Nolwenn Cadic <98824442+Nolwenn-cadic-sonarsource@users.noreply.github.com> --- .../src/components/SpotlightTour.tsx | 13 +-- .../__tests__/SpotlightTour-test.tsx | 32 +++++-- .../overview/branches/MeasuresCardNumber.tsx | 14 ++- .../overview/branches/MeasuresCardPanel.tsx | 1 + .../overview/branches/QualityGatePanel.tsx | 1 + .../branches/QualityGatePanelSection.tsx | 9 +- .../QualityGatePanelSection-test.tsx | 66 ++++++++++++-- .../components/QualityGateConditions.tsx | 31 +++++-- .../QualityGateSimplifiedCondition.tsx | 88 ++++++++++++++++++ .../ZeroNewIssuesSimplificationGuide.tsx | 89 +++++++++++++++++++ .../QualityGateSimplifiedCondition-test.tsx | 67 ++++++++++++++ .../pullRequests/PullRequestOverview.tsx | 40 +++++++-- .../__tests__/PullRequestOverview-it.tsx | 87 +++++++++++++++--- .../CaYCConditionsSimplificationGuide.tsx | 35 ++++++-- .../components/__tests__/QualityGate-it.tsx | 26 ++++++ server/sonar-web/src/main/js/types/users.ts | 1 + .../server/user/ws/DismissNoticeActionIT.java | 74 +++++++-------- .../server/user/ws/DismissNoticeAction.java | 14 +-- .../resources/org/sonar/l10n/core.properties | 17 +++- 19 files changed, 595 insertions(+), 110 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/overview/components/QualityGateSimplifiedCondition.tsx create mode 100644 server/sonar-web/src/main/js/apps/overview/components/ZeroNewIssuesSimplificationGuide.tsx create mode 100644 server/sonar-web/src/main/js/apps/overview/components/__tests__/QualityGateSimplifiedCondition-test.tsx diff --git a/server/sonar-web/design-system/src/components/SpotlightTour.tsx b/server/sonar-web/design-system/src/components/SpotlightTour.tsx index 6e162316776..fcc529ca98b 100644 --- a/server/sonar-web/design-system/src/components/SpotlightTour.tsx +++ b/server/sonar-web/design-system/src/components/SpotlightTour.tsx @@ -139,11 +139,14 @@ function TooltipComponent({
{step.content}
- - {stepXofYLabel - ? stepXofYLabel(index + 1, size) - : intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })} - + {(stepXofYLabel || size > 1) && ( + + {stepXofYLabel + ? stepXofYLabel(index + 1, size) + : intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })} + + )} +
{index > 0 && ( diff --git a/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx b/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx index 1d14f358b7b..4b0ab75076f 100644 --- a/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx @@ -30,39 +30,40 @@ it('should display the spotlight tour', async () => { expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); expect(screen.getByRole('alertdialog')).toHaveTextContent( - 'Trust The FooFoo bar is bazstep 1 of 5next' + 'Trust The FooFoo bar is bazstep 1 of 5next', ); + expect(screen.getByText('step 1 of 5')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'next' })); expect(screen.getByRole('alertdialog')).toHaveTextContent( - 'Trust The BazBaz foo is barstep 2 of 5go_backnext' + 'Trust The BazBaz foo is barstep 2 of 5go_backnext', ); expect(callback).toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: 'next' })); expect(screen.getByRole('alertdialog')).toHaveTextContent( - 'Trust The BarBar baz is foostep 3 of 5go_backnext' + 'Trust The BarBar baz is foostep 3 of 5go_backnext', ); await user.click(screen.getByRole('button', { name: 'next' })); expect(screen.getByRole('alertdialog')).toHaveTextContent( - 'Trust The Foo 2Foo baz is barstep 4 of 5go_backnext' + 'Trust The Foo 2Foo baz is barstep 4 of 5go_backnext', ); await user.click(screen.getByRole('button', { name: 'go_back' })); expect(screen.getByRole('alertdialog')).toHaveTextContent( - 'Trust The BarBar baz is foostep 3 of 5go_backnext' + 'Trust The BarBar baz is foostep 3 of 5go_backnext', ); await user.click(screen.getByRole('button', { name: 'next' })); await user.click(screen.getByRole('button', { name: 'next' })); expect(screen.getByRole('alertdialog')).toHaveTextContent( - 'Trust The Baz 2Baz bar is foostep 5 of 5go_backclose' + 'Trust The Baz 2Baz bar is foostep 5 of 5go_backclose', ); expect(screen.queryByRole('button', { name: 'next' })).not.toBeInTheDocument(); @@ -102,6 +103,23 @@ it('should allow the customization of button labels', async () => { expect(screen.getByRole('button', { name: 'close_me' })).toBeInTheDocument(); }); +it('should not display steps counter when there is only one step and no render method', async () => { + renderSpotlightTour({ + steps: [ + { + target: '#step1', + content: 'Foo bar is baz', + title: 'Trust The Foo', + placement: 'top', + }, + ], + stepXofYLabel: undefined, + }); + + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + expect(screen.queryByText('step 1 of 1')).not.toBeInTheDocument(); +}); + function renderSpotlightTour(props: Partial = {}) { return renderWithContext(
@@ -149,6 +167,6 @@ function renderSpotlightTour(props: Partial = {}) { ]} {...props} /> -
+
, ); } diff --git a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardNumber.tsx b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardNumber.tsx index 9c512ce6bd2..340dc2863c9 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardNumber.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardNumber.tsx @@ -32,13 +32,22 @@ interface Props { value: string; failingConditionMetric: MetricKey; requireLabel: string; + guidingKeyOnError?: string; } export default function MeasuresCardNumber( props: React.PropsWithChildren>, ) { - const { label, value, failedConditions, url, failingConditionMetric, requireLabel, ...rest } = - props; + const { + label, + value, + failedConditions, + url, + failingConditionMetric, + requireLabel, + guidingKeyOnError, + ...rest + } = props; const failed = Boolean( failedConditions.find((condition) => condition.metric === failingConditionMetric), @@ -51,6 +60,7 @@ export default function MeasuresCardNumber( metric={failingConditionMetric} label={label} failed={failed} + data-guiding-id={failed ? guidingKeyOnError : undefined} {...rest} > {failed && } diff --git a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardPanel.tsx index f123188286c..b0bbc587093 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardPanel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardPanel.tsx @@ -72,6 +72,7 @@ export default function MeasuresCardPanel(props: React.PropsWithChildren) count: newViolations, }, )} + guidingKeyOnError="overviewZeroNewIssuesSimplification" /> ))}
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx b/server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx index c9bb970d102..b7a6861c270 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx @@ -27,13 +27,16 @@ import { QualityGateStatus, QualityGateStatusConditionEnhanced, } from '../../../types/quality-gates'; +import { QualityGate } from '../../../types/types'; import QualityGateConditions from '../components/QualityGateConditions'; +import ZeroNewIssuesSimplificationGuide from '../components/ZeroNewIssuesSimplificationGuide'; export interface QualityGatePanelSectionProps { branchLike?: BranchLike; isApplication?: boolean; isLastStatus?: boolean; qgStatus: QualityGateStatus; + qualityGate?: QualityGate; } function splitConditions( @@ -54,7 +57,7 @@ function splitConditions( } export function QualityGatePanelSection(props: QualityGatePanelSectionProps) { - const { isApplication, isLastStatus, qgStatus } = props; + const { isApplication, isLastStatus, qgStatus, qualityGate } = props; const [collapsed, setCollapsed] = React.useState(false); const toggle = React.useCallback(() => { @@ -101,10 +104,14 @@ export function QualityGatePanelSection(props: QualityGatePanelSectionProps) { )} + {qualityGate?.isBuiltIn && ( + + )} )} diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/QualityGatePanelSection-test.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/QualityGatePanelSection-test.tsx index 05d84f0e223..e08c3845950 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/QualityGatePanelSection-test.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/QualityGatePanelSection-test.tsx @@ -20,9 +20,14 @@ import { screen } from '@testing-library/react'; import * as React from 'react'; +import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; +import { mockQualityGate, mockQualityGateStatus } from '../../../../helpers/mocks/quality-gates'; +import { mockLoggedInUser } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { byRole } from '../../../../helpers/testSelector'; import { MetricKey } from '../../../../types/metrics'; import { CaycStatus, Status } from '../../../../types/types'; +import { CurrentUser, NoticeType } from '../../../../types/users'; import QualityGatePanelSection, { QualityGatePanelSectionProps } from '../QualityGatePanelSection'; const failedConditions = [ @@ -31,7 +36,7 @@ const failedConditions = [ measure: { metric: { id: 'metricId1', - key: 'metricKey1', + key: MetricKey.new_coverage, name: 'metricName1', type: 'metricType1', }, @@ -44,7 +49,7 @@ const failedConditions = [ measure: { metric: { id: 'metricId2', - key: 'metricKey2', + key: MetricKey.security_hotspots, name: 'metricName2', type: 'metricType2', }, @@ -52,20 +57,33 @@ const failedConditions = [ metric: MetricKey.security_hotspots, op: 'op2', }, + { + level: 'ERROR' as Status, + measure: { + metric: { + id: 'metricId2', + key: MetricKey.new_violations, + name: 'metricName2', + type: 'metricType2', + }, + }, + metric: MetricKey.new_violations, + op: 'op2', + }, ]; -const qgStatus = { +const qgStatus = mockQualityGateStatus({ caycStatus: CaycStatus.Compliant, failedConditions, key: 'qgStatusKey', name: 'qgStatusName', status: 'ERROR' as Status, -}; +}); it('should render correctly for an application with 1 new code condition and 1 overall code condition', async () => { renderQualityGatePanelSection(); - expect(await screen.findByText('quality_gates.conditions.new_code_1')).toBeInTheDocument(); + expect(await screen.findByText('quality_gates.conditions.new_code_x.2')).toBeInTheDocument(); expect(await screen.findByText('quality_gates.conditions.overall_code_1')).toBeInTheDocument(); }); @@ -79,6 +97,40 @@ it('should render correctly for a project with 1 new code condition', () => { expect(screen.queryByText('quality_gates.conditions.overall_code_1')).not.toBeInTheDocument(); }); -function renderQualityGatePanelSection(props: Partial = {}) { - return renderComponent(); +it('should render correctly 0 New issues onboarding', async () => { + renderQualityGatePanelSection({ + isApplication: false, + qgStatus: { ...qgStatus, failedConditions: [failedConditions[2]] }, + qualityGate: mockQualityGate({ isBuiltIn: true }), + }); + + expect(screen.queryByText('quality_gates.conditions.new_code_1')).not.toBeInTheDocument(); + expect(await byRole('alertdialog').find()).toBeInTheDocument(); +}); + +it('should not render 0 New issues onboarding for user who dismissed it', async () => { + renderQualityGatePanelSection( + { + isApplication: false, + qgStatus: { ...qgStatus, failedConditions: [failedConditions[2]] }, + qualityGate: mockQualityGate({ isBuiltIn: true }), + }, + mockLoggedInUser({ + dismissedNotices: { [NoticeType.OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION]: true }, + }), + ); + + expect(screen.queryByText('quality_gates.conditions.new_code_1')).not.toBeInTheDocument(); + expect(await byRole('alertdialog').query()).not.toBeInTheDocument(); +}); + +function renderQualityGatePanelSection( + props: Partial = {}, + currentUser: CurrentUser = mockLoggedInUser(), +) { + return renderComponent( + + + , + ); } diff --git a/server/sonar-web/src/main/js/apps/overview/components/QualityGateConditions.tsx b/server/sonar-web/src/main/js/apps/overview/components/QualityGateConditions.tsx index b090edb6457..b4b0ad79cf3 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/QualityGateConditions.tsx +++ b/server/sonar-web/src/main/js/apps/overview/components/QualityGateConditions.tsx @@ -22,9 +22,11 @@ import { sortBy } from 'lodash'; import * as React from 'react'; import { translate } from '../../../helpers/l10n'; import { BranchLike } from '../../../types/branch-like'; +import { MetricKey } from '../../../types/metrics'; import { QualityGateStatusConditionEnhanced } from '../../../types/quality-gates'; import { Component } from '../../../types/types'; import QualityGateCondition from './QualityGateCondition'; +import QualityGateSimplifiedCondition from './QualityGateSimplifiedCondition'; const LEVEL_ORDER = ['ERROR', 'WARN']; @@ -33,16 +35,25 @@ export interface QualityGateConditionsProps { component: Pick; collapsible?: boolean; failedConditions: QualityGateStatusConditionEnhanced[]; + isBuiltInQualityGate?: boolean; } const MAX_CONDITIONS = 5; export function QualityGateConditions(props: QualityGateConditionsProps) { - const { branchLike, collapsible, component, failedConditions } = props; + const { branchLike, collapsible, component, failedConditions, isBuiltInQualityGate } = props; const [collapsed, toggleCollapsed] = React.useState(Boolean(collapsible)); const handleToggleCollapsed = React.useCallback(() => toggleCollapsed(!collapsed), [collapsed]); + const isSimplifiedCondition = React.useCallback( + (condition: QualityGateStatusConditionEnhanced) => { + const { metric } = condition.measure; + return metric.key === MetricKey.new_violations && isBuiltInQualityGate; + }, + [isBuiltInQualityGate], + ); + const sortedConditions = sortBy(failedConditions, (condition) => LEVEL_ORDER.indexOf(condition.level), ); @@ -62,11 +73,19 @@ export function QualityGateConditions(props: QualityGateConditionsProps) {
    {renderConditions.map((condition) => (
    - + {isSimplifiedCondition(condition) ? ( + + ) : ( + + )}
    ))} diff --git a/server/sonar-web/src/main/js/apps/overview/components/QualityGateSimplifiedCondition.tsx b/server/sonar-web/src/main/js/apps/overview/components/QualityGateSimplifiedCondition.tsx new file mode 100644 index 00000000000..549c9a6da68 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/components/QualityGateSimplifiedCondition.tsx @@ -0,0 +1,88 @@ +/* + * 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 { Highlight, LinkBox } from 'design-system'; +import * as React from 'react'; +import { propsToIssueParams } from '../../../components/shared/utils'; +import { getBranchLikeQuery } from '../../../helpers/branch-like'; +import { translate } from '../../../helpers/l10n'; +import { formatMeasure, isDiffMetric, localizeMetric } from '../../../helpers/measures'; +import { getComponentIssuesUrl } from '../../../helpers/urls'; +import { BranchLike } from '../../../types/branch-like'; +import { MetricKey, MetricType } from '../../../types/metrics'; +import { QualityGateStatusConditionEnhanced } from '../../../types/quality-gates'; +import { Component } from '../../../types/types'; + +interface Props { + branchLike?: BranchLike; + component: Pick; + condition: QualityGateStatusConditionEnhanced; +} + +export default function QualityGateSimplifiedCondition({ + branchLike, + component, + condition, +}: Readonly) { + const getPrimaryText = () => { + const { measure } = condition; + const { metric } = measure; + const isDiff = isDiffMetric(metric.key); + + const subText = + !isDiff && condition.period != null + ? `${localizeMetric(metric.key)} ${translate('quality_gates.conditions.new_code')}` + : localizeMetric(metric.key); + + return subText; + }; + + const { measure } = condition; + const { metric } = measure; + + const value = (condition.period ? measure.period?.value : measure.value) as string; + + const formattedValue = formatMeasure(value, MetricType.ShortInteger, { + decimals: 0, + omitExtraDecimalZeros: metric.type === MetricType.Percent, + }); + + return ( + +
    + {formattedValue} + + {getPrimaryText()} + +
    +
    + ); +} diff --git a/server/sonar-web/src/main/js/apps/overview/components/ZeroNewIssuesSimplificationGuide.tsx b/server/sonar-web/src/main/js/apps/overview/components/ZeroNewIssuesSimplificationGuide.tsx new file mode 100644 index 00000000000..36fdb179564 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/components/ZeroNewIssuesSimplificationGuide.tsx @@ -0,0 +1,89 @@ +/* + * 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 { SpotlightTour, SpotlightTourStep } from 'design-system'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { dismissNotice } from '../../../api/users'; +import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; +import Link from '../../../components/common/Link'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { QualityGate } from '../../../types/types'; +import { NoticeType } from '../../../types/users'; + +interface Props { + qualityGate: QualityGate; +} + +export default function ZeroNewIssuesSimplificationGuide({ qualityGate }: Readonly) { + const { currentUser, updateDismissedNotices } = React.useContext(CurrentUserContext); + const shouldRun = + currentUser.isLoggedIn && + !currentUser.dismissedNotices[NoticeType.OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION]; + + const steps: SpotlightTourStep[] = [ + { + target: `[data-guiding-id="overviewZeroNewIssuesSimplification"]`, + content: ( + <> +

    + + {translateWithParameters( + 'overview.quality_gates.conditions.condition_simplification_tour.content1.link', + qualityGate.name, + )} + + ), + }} + /> +

    +

    + {translate('overview.quality_gates.conditions.condition_simplification_tour.content2')} +

    + + ), + title: translate('overview.quality_gates.conditions.condition_simplification_tour.title'), + placement: 'right', + }, + ]; + + const onCallback = async (props: { action: string; type: string }) => { + if (props.action === 'close' && props.type === 'tour:end' && shouldRun) { + await dismissNotice(NoticeType.OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION); + updateDismissedNotices(NoticeType.OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION, true); + } + }; + + return ( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/overview/components/__tests__/QualityGateSimplifiedCondition-test.tsx b/server/sonar-web/src/main/js/apps/overview/components/__tests__/QualityGateSimplifiedCondition-test.tsx new file mode 100644 index 00000000000..d87537973c0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/components/__tests__/QualityGateSimplifiedCondition-test.tsx @@ -0,0 +1,67 @@ +/* + * 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 React from 'react'; +import { mockQualityGateStatusConditionEnhanced } from '../../../../helpers/mocks/quality-gates'; +import { mockMetric } from '../../../../helpers/testMocks'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { MetricKey, MetricType } from '../../../../types/metrics'; +import { QualityGateStatusConditionEnhanced } from '../../../../types/quality-gates'; +import QualityGateCondition from '../QualityGateCondition'; +import QualityGateSimplifiedCondition from '../QualityGateSimplifiedCondition'; + +it('should show simplified condition', async () => { + renderQualityGateCondition({ + condition: quickMock(MetricKey.new_violations, MetricType.Integer), + }); + expect(await screen.findByText('metric.new_violations.name')).toBeInTheDocument(); +}); + +function renderQualityGateCondition(props: Partial) { + return renderComponent( + , + ); +} + +function quickMock( + metric: MetricKey, + type = MetricType.Rating, + addPeriod = false, + value = '3', +): QualityGateStatusConditionEnhanced { + return mockQualityGateStatusConditionEnhanced({ + error: '1', + measure: { + metric: mockMetric({ + key: metric, + name: metric, + type, + }), + value, + ...(addPeriod ? { period: { value, index: 1 } } : {}), + }, + metric, + ...(addPeriod ? { period: 1 } : {}), + }); +} 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 9da9ac8c86b..5c0d965b20c 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 @@ -22,16 +22,18 @@ import { uniq } from 'lodash'; import * as React from 'react'; import { useEffect, useState } from 'react'; import { getMeasuresWithMetrics } from '../../../api/measures'; +import { fetchQualityGate, getGateForProject } from '../../../api/quality-gates'; import { getBranchLikeQuery } from '../../../helpers/branch-like'; import { enhanceConditionWithMeasure, enhanceMeasuresWithMetrics } from '../../../helpers/measures'; import { isDefined } from '../../../helpers/types'; import { useBranchStatusQuery } from '../../../queries/branch'; import { PullRequest } from '../../../types/branch-like'; -import { Component, MeasureEnhanced } from '../../../types/types'; +import { Component, MeasureEnhanced, QualityGate } from '../../../types/types'; import MeasuresCardPanel from '../branches/MeasuresCardPanel'; import BranchQualityGate from '../components/BranchQualityGate'; import IgnoredConditionWarning from '../components/IgnoredConditionWarning'; import MetaTopBar from '../components/MetaTopBar'; +import ZeroNewIssuesSimplificationGuide from '../components/ZeroNewIssuesSimplificationGuide'; import '../styles.css'; import { PR_METRICS, Status } from '../utils'; import SonarLintAd from './SonarLintAd'; @@ -43,14 +45,18 @@ interface Props { export default function PullRequestOverview(props: Props) { const { component, branchLike } = props; - const [loadingMeasure, setLoadingMeasure] = useState(false); + const [isLoadingMeasures, setIsLoadingMeasures] = useState(false); const [measures, setMeasures] = useState([]); - const { data: { conditions, ignoredConditions, status } = {}, isLoading } = - useBranchStatusQuery(component); - const loading = isLoading || loadingMeasure; + const { + data: { conditions, ignoredConditions, status } = {}, + isLoading: isLoadingBranchStatusesData, + } = useBranchStatusQuery(component); + const [isLoadingQualityGate, setIsLoadingQualityGate] = useState(false); + const [qualityGate, setQualityGate] = useState(); + const isLoading = isLoadingBranchStatusesData || isLoadingMeasures || isLoadingQualityGate; useEffect(() => { - setLoadingMeasure(true); + setIsLoadingMeasures(true); const metricKeys = conditions !== undefined @@ -64,17 +70,31 @@ export default function PullRequestOverview(props: Props) { getMeasuresWithMetrics(component.key, metricKeys, getBranchLikeQuery(branchLike)).then( ({ component, metrics }) => { if (component.measures) { - setLoadingMeasure(false); + setIsLoadingMeasures(false); setMeasures(enhanceMeasuresWithMetrics(component.measures || [], metrics)); } }, () => { - setLoadingMeasure(false); + setIsLoadingMeasures(false); }, ); }, [branchLike, component.key, conditions]); - if (loading) { + useEffect(() => { + async function fetchQualityGateDate() { + setIsLoadingQualityGate(true); + + const qualityGate = await getGateForProject({ project: component.key }); + const qgDetails = await fetchQualityGate({ name: qualityGate.name }); + + setQualityGate(qgDetails); + setIsLoadingQualityGate(false); + } + + fetchQualityGateDate(); + }, [component.key]); + + if (isLoading) { return (
    @@ -119,6 +139,8 @@ export default function PullRequestOverview(props: Props) { measures={measures} /> + {qualityGate?.isBuiltIn && } +
    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 a9f02fd4f73..3a80968c392 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 @@ -20,11 +20,14 @@ 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 { fetchQualityGate, getQualityGateProjectStatus } from '../../../../api/quality-gates'; import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; import { mockPullRequest } from '../../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../../helpers/mocks/component'; -import { mockQualityGateProjectCondition } from '../../../../helpers/mocks/quality-gates'; +import { + mockQualityGate, + mockQualityGateProjectCondition, +} from '../../../../helpers/mocks/quality-gates'; import { mockLoggedInUser, mockMeasure, mockMetric } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; import { byLabelText, byRole } from '../../../../helpers/testSelector'; @@ -32,6 +35,7 @@ import { ComponentPropsType } from '../../../../helpers/testUtils'; import { ComponentQualifier } from '../../../../types/component'; import { MetricKey, MetricType } from '../../../../types/metrics'; import { CaycStatus } from '../../../../types/types'; +import { NoticeType } from '../../../../types/users'; import PullRequestOverview from '../PullRequestOverview'; jest.mock('../../../../api/measures', () => { @@ -55,27 +59,25 @@ jest.mock('../../../../api/measures', () => { mockMeasure({ metric: MetricKey.new_lines, }), + mockMeasure({ + metric: MetricKey.new_violations, + }), ], }, metrics: [ mockMetric({ key: MetricKey.new_coverage }), - mockMetric({ - key: MetricKey.duplicated_lines, - }), + mockMetric({ key: MetricKey.duplicated_lines }), mockMetric({ key: MetricKey.new_lines, type: MetricType.ShortInteger }), - mockMetric({ - key: MetricKey.new_bugs, - type: MetricType.Integer, - }), + mockMetric({ key: MetricKey.new_bugs, type: MetricType.Integer }), + mockMetric({ key: MetricKey.new_violations }), ], }), }; }); jest.mock('../../../../api/quality-gates', () => { - const { mockQualityGateProjectStatus, mockQualityGateApplicationStatus } = jest.requireActual( - '../../../../helpers/mocks/quality-gates', - ); + const { mockQualityGateProjectStatus, mockQualityGateApplicationStatus, mockQualityGate } = + jest.requireActual('../../../../helpers/mocks/quality-gates'); const { MetricKey } = jest.requireActual('../../../../types/metrics'); return { getQualityGateProjectStatus: jest.fn().mockResolvedValue( @@ -110,6 +112,8 @@ jest.mock('../../../../api/quality-gates', () => { }), ), getApplicationQualityGate: jest.fn().mockResolvedValue(mockQualityGateApplicationStatus()), + getGateForProject: jest.fn().mockResolvedValue(mockQualityGate({ isBuiltIn: true })), + fetchQualityGate: jest.fn().mockResolvedValue(mockQualityGate({ isBuiltIn: true })), }; }); @@ -216,11 +220,68 @@ it('renders SL promotion', async () => { ).not.toBeInTheDocument(); }); +it('should render correctly 0 New issues onboarding', async () => { + jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ + status: 'ERROR', + conditions: [ + mockQualityGateProjectCondition({ + status: 'ERROR', + errorThreshold: '0', + metricKey: MetricKey.new_violations, + actualValue: '1', + }), + ], + caycStatus: CaycStatus.Compliant, + ignoredConditions: false, + }); + jest.mocked(fetchQualityGate).mockResolvedValueOnce(mockQualityGate({ isBuiltIn: true })); + + renderPullRequestOverview(); + + expect( + await byLabelText('overview.quality_gate_x.overview.gate.ERROR').find(), + ).toBeInTheDocument(); + expect(await byRole('alertdialog').find()).toBeInTheDocument(); +}); + +it('should not render 0 New issues onboarding when user dismissed it', async () => { + jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ + status: 'ERROR', + conditions: [ + mockQualityGateProjectCondition({ + status: 'ERROR', + errorThreshold: '0', + metricKey: MetricKey.new_violations, + actualValue: '1', + }), + ], + caycStatus: CaycStatus.Compliant, + ignoredConditions: false, + }); + jest.mocked(fetchQualityGate).mockResolvedValueOnce(mockQualityGate({ isBuiltIn: true })); + + renderPullRequestOverview( + {}, + mockLoggedInUser({ + dismissedNotices: { [NoticeType.OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION]: true }, + }), + ); + + await waitFor(async () => + expect( + await byLabelText('overview.quality_gate_x.overview.gate.ERROR').find(), + ).toBeInTheDocument(), + ); + + expect(await byRole('alertdialog').query()).not.toBeInTheDocument(); +}); + function renderPullRequestOverview( props: Partial> = {}, + currentUser = mockLoggedInUser(), ) { renderComponent( - + {translate('quality_gates.cayc.condition_simplification_tour.page_1.content1')}

    + ), + title: translate('quality_gates.cayc.condition_simplification_tour.page_1.title'), + placement: 'right', + }, + { + target: '[data-guiding-id="caycConditionsSimplification"]', + content: ( + <> +

    + {translate('quality_gates.cayc.condition_simplification_tour.page_2.content1')} +

    +

    {translate('quality_gates.cayc.condition_simplification_tour.page_2.content2')}

    + + ), + title: translate('quality_gates.cayc.condition_simplification_tour.page_2.title'), + placement: 'right', + }, { target: '[data-guiding-id="caycConditionsSimplification"]', content: ( <>

    - {translate('quality_gates.cayc.condition_simplification_tour.content1')} + {translate('quality_gates.cayc.condition_simplification_tour.page_3.content1')}

    -

    {translate('quality_gates.cayc.condition_simplification_tour.content2')}

    + + {translate('quality_gates.cayc.condition_simplification_tour.page_3.content2')} + ), - title: translate('quality_gates.cayc.condition_simplification_tour.title'), + title: translate('quality_gates.cayc.condition_simplification_tour.page_3.title'), placement: 'right', }, ]; - const onCallback = async (props: { action: string }) => { - if (props.action === 'close' && shouldRun) { + const onCallback = async (props: { action: string; type: string }) => { + if (props.action === 'close' && props.type === 'tour:end' && shouldRun) { await dismissNotice(NoticeType.QG_CAYC_CONDITIONS_SIMPLIFICATION); updateDismissedNotices(NoticeType.QG_CAYC_CONDITIONS_SIMPLIFICATION, true); } @@ -56,6 +80,7 @@ export default function CaYCConditionsSimplificationGuide() { return ( { + await user.click(byRole('alertdialog').byRole('button', { name: 'next' }).get()); + }); + + expect( + byRole('alertdialog') + .byText('quality_gates.cayc.condition_simplification_tour.page_2.title') + .get(), + ).toBeInTheDocument(); + + await act(async () => { + await user.click(byRole('alertdialog').byRole('button', { name: 'next' }).get()); + }); + + expect( + byRole('alertdialog') + .byText('quality_gates.cayc.condition_simplification_tour.page_3.title') + .get(), + ).toBeInTheDocument(); + await act(async () => { await user.click(byRole('alertdialog').byRole('button', { name: 'dismiss' }).get()); }); diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index 33ec00a96e1..a292269835b 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -34,6 +34,7 @@ export enum NoticeType { SONARLINT_AD = 'sonarlintAd', ISSUE_GUIDE = 'issueCleanCodeGuide', QG_CAYC_CONDITIONS_SIMPLIFICATION = 'qualityGateCaYCConditionsSimplification', + OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = 'overviewZeroNewIssuesSimplification', } export interface LoggedInUser extends CurrentUser, UserActive { diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java index 68ba21b934b..d761fe011c4 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java @@ -19,9 +19,13 @@ */ package org.sonar.server.user.ws; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.util.Optional; import org.junit.Rule; import org.junit.Test; +import org.junit.runner.RunWith; import org.sonar.api.utils.System2; import org.sonar.db.DbTester; import org.sonar.db.property.PropertyDto; @@ -33,7 +37,9 @@ import org.sonar.server.ws.WsActionTester; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.sonar.server.user.ws.DismissNoticeAction.AVAILABLE_NOTICE_KEYS; +@RunWith(DataProviderRunner.class) public class DismissNoticeActionIT { @Rule @@ -43,48 +49,6 @@ public class DismissNoticeActionIT { private final WsActionTester tester = new WsActionTester(new DismissNoticeAction(userSessionRule, db.getDbClient())); - @Test - public void dismiss_educationPrinciples() { - userSessionRule.logIn(); - - TestResponse testResponse = tester.newRequest() - .setParam("notice", "educationPrinciples") - .execute(); - - assertThat(testResponse.getStatus()).isEqualTo(204); - - Optional propertyDto = db.properties().findFirstUserProperty(userSessionRule.getUuid(), "user.dismissedNotices.educationPrinciples"); - assertThat(propertyDto).isPresent(); - } - - @Test - public void dismiss_sonarlintAd() { - userSessionRule.logIn(); - - TestResponse testResponse = tester.newRequest() - .setParam("notice", "sonarlintAd") - .execute(); - - assertThat(testResponse.getStatus()).isEqualTo(204); - - Optional propertyDto = db.properties().findFirstUserProperty(userSessionRule.getUuid(), "user.dismissedNotices.sonarlintAd"); - assertThat(propertyDto).isPresent(); - } - - @Test - public void execute_whenNoticeIsIssueCleanCodeGuide_shouldDismissCorrespondingNotice() { - userSessionRule.logIn(); - - TestResponse testResponse = tester.newRequest() - .setParam("notice", "issueCleanCodeGuide") - .execute(); - - assertThat(testResponse.getStatus()).isEqualTo(204); - - Optional propertyDto = db.properties().findFirstUserProperty(userSessionRule.getUuid(), "user.dismissedNotices.issueCleanCodeGuide"); - assertThat(propertyDto).isPresent(); - } - @Test public void authentication_is_required() { TestRequest testRequest = tester.newRequest() @@ -114,7 +78,8 @@ public class DismissNoticeActionIT { assertThatThrownBy(testRequest::execute) .isInstanceOf(IllegalArgumentException.class) .hasMessage( - "Value of parameter 'notice' (not_supported_value) must be one of: [educationPrinciples, sonarlintAd, issueCleanCodeGuide, qualityGateCaYCConditionsSimplification]"); + "Value of parameter 'notice' (not_supported_value) must be one of: [educationPrinciples, sonarlintAd, issueCleanCodeGuide, qualityGateCaYCConditionsSimplification, " + + "overviewZeroNewIssuesSimplification]"); } @Test @@ -125,11 +90,32 @@ public class DismissNoticeActionIT { assertThat(db.properties().findFirstUserProperty(userSessionRule.getUuid(), "user.dismissedNotices.educationPrinciples")).isPresent(); TestResponse testResponse = tester.newRequest() - .setParam("notice", "sonarlintAd") + .setParam("notice", "educationPrinciples") .execute(); assertThat(testResponse.getStatus()).isEqualTo(204); assertThat(db.properties().findFirstUserProperty(userSessionRule.getUuid(), "user.dismissedNotices.educationPrinciples")).isPresent(); } + @Test + @UseDataProvider("noticeKeys") + public void dismiss_notice(String noticeKey) { + userSessionRule.logIn(); + + TestResponse testResponse = tester.newRequest() + .setParam("notice", noticeKey) + .execute(); + + assertThat(testResponse.getStatus()).isEqualTo(204); + + Optional propertyDto = db.properties().findFirstUserProperty(userSessionRule.getUuid(), "user.dismissedNotices." + noticeKey); + assertThat(propertyDto).isPresent(); + } + + @DataProvider + public static Object[][] noticeKeys() { + return AVAILABLE_NOTICE_KEYS.stream() + .map(noticeKey -> new Object[] {noticeKey}) + .toArray(Object[][]::new); + } } diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java index 352c599e537..8a9b6c0de48 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java @@ -38,8 +38,10 @@ public class DismissNoticeAction implements UsersWsAction { private static final String SONARLINT_AD = "sonarlintAd"; private static final String ISSUE_CLEAN_CODE_GUIDE = "issueCleanCodeGuide"; private static final String QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION = "qualityGateCaYCConditionsSimplification"; + private static final String OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = "overviewZeroNewIssuesSimplification"; - protected static final List AVAILABLE_NOTICE_KEYS = List.of(EDUCATION_PRINCIPLES, SONARLINT_AD, ISSUE_CLEAN_CODE_GUIDE, QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION); + protected static final List AVAILABLE_NOTICE_KEYS = List.of(EDUCATION_PRINCIPLES, SONARLINT_AD, ISSUE_CLEAN_CODE_GUIDE, QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION, + OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION); public static final String USER_DISMISS_CONSTANT = "user.dismissedNotices."; private final UserSession userSession; @@ -86,14 +88,12 @@ public class DismissNoticeAction implements UsersWsAction { .setKey(paramKey) .build(); - if (!dbClient.propertiesDao().selectByQuery(query, dbSession).isEmpty()) { - // already dismissed - response.noContent(); + if (dbClient.propertiesDao().selectByQuery(query, dbSession).isEmpty()) { + PropertyDto property = new PropertyDto().setUserUuid(currentUserUuid).setKey(paramKey); + dbClient.propertiesDao().saveProperty(dbSession, property); + dbSession.commit(); } - PropertyDto property = new PropertyDto().setUserUuid(currentUserUuid).setKey(paramKey); - dbClient.propertiesDao().saveProperty(dbSession, property); - dbSession.commit(); response.noContent(); } } 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 48dc14d3936..73e397be9d9 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2127,7 +2127,7 @@ quality_gates.copy=Copy Quality Gate quality_gates.cannot_set_default_no_cayc=You must make this quality gate Clean as You Code compliant to make this the default quality gate. quality_gates.cannot_copy_no_cayc=You must make this quality gate Clean as You Code compliant to copy. quality_gates.is_default_no_conditions=This is the default quality gate, but it has no configured conditions. Please configure at least 1 condition for this quality gate. -quality_gates.is_built_in.description=The only Quality Gate you need to {link} +quality_gates.is_built_in.description=The only quality gate you need to practice {link} quality_gates.conditions=Conditions quality_gates.conditions.help=Your project will fail the Quality Gate if it crosses any metric thresholds set for New Code or Overall Code. quality_gates.conditions.help.link=See also: Clean as You Code @@ -2215,9 +2215,14 @@ quality_gates.cayc.review_update_modal.header=Fix "{0}" to comply with Clean as quality_gates.cayc.review_update_modal.confirm_text=Fix Quality Gate quality_gates.cayc.review_update_modal.description1=This quality gate will be updated to comply with {cayc_link}. Please review the changes below. quality_gates.cayc.review_update_modal.description2=All other conditions will be preserved -quality_gates.cayc.condition_simplification_tour.title=One condition, zero issues -quality_gates.cayc.condition_simplification_tour.content1=One single condition ensures that new code has 0 issues. -quality_gates.cayc.condition_simplification_tour.content2=This condition replaces the three conditions on Security rating, Reliability rating and Maintainability rating. +quality_gates.cayc.condition_simplification_tour.page_1.title='Clean as You Code' ready! +quality_gates.cayc.condition_simplification_tour.page_1.content1=The conditions in this quality gate have been updated to ensure that any code added or changed is clean. +quality_gates.cayc.condition_simplification_tour.page_2.title=One condition, zero issues +quality_gates.cayc.condition_simplification_tour.page_2.content1=One single condition ensures that new code has no issues. +quality_gates.cayc.condition_simplification_tour.page_2.content2=This condition replaces the three conditions on Security rating, Reliability rating and Maintainability rating. +quality_gates.cayc.condition_simplification_tour.page_3.title=Resolve pending issues +quality_gates.cayc.condition_simplification_tour.page_3.content1=Starting now, every issue in new code must be resolved for a project to pass this quality gate. +quality_gates.cayc.condition_simplification_tour.page_3.content2=Learn more: Issue resolutions quality_gates.cayc.new_maintainability_rating.A=Technical debt ratio is less than {0} quality_gates.cayc.new_maintainability_rating=Technical debt ratio is greater than {1} quality_gates.cayc.new_reliability_rating.A=No bugs @@ -3764,6 +3769,10 @@ overview.quality_gate.conditions.cayc.details=Fixing this quality gate will help overview.quality_gate.conditions.cayc.details_with_link=Fixing {link} will help you achieve a Clean Code state. overview.quality_gate.conditions.non_cayc.warning.link=this quality gate overview.quality_gate.conditions.cayc.link=Learn why +overview.quality_gates.conditions.condition_simplification_tour.title=One condition, zero issues +overview.quality_gates.conditions.condition_simplification_tour.content1=A new condition was introduced in {link} to ensure that new code has no issues. +overview.quality_gates.conditions.condition_simplification_tour.content1.link={0} quality gate +overview.quality_gates.conditions.condition_simplification_tour.content2=Starting now, every issue in new code must be resolved for a project to pass this quality gate. overview.quality_gate.application.non_cayc.projects_x={0} project(s) in this application use a Quality Gate that does not comply with Clean as You Code overview.quality_gate.show_project_conditions_x=Show failed conditions for project {0} overview.quality_gate.hide_project_conditions_x=Hide failed conditions for project {0} -- 2.39.5