Browse Source

MMF-3429 SonarWay smooth transition (#9791)

Co-authored-by: Andrey Luiz <andrey.luiz@sonarsource.com>
Co-authored-by: Nolwenn Cadic <98824442+Nolwenn-cadic-sonarsource@users.noreply.github.com>
tags/10.3.0.82913
Philippe Perrin 7 months ago
parent
commit
2d81345e25
19 changed files with 595 additions and 110 deletions
  1. 8
    5
      server/sonar-web/design-system/src/components/SpotlightTour.tsx
  2. 25
    7
      server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx
  3. 12
    2
      server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardNumber.tsx
  4. 1
    0
      server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardPanel.tsx
  5. 1
    0
      server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanel.tsx
  6. 8
    1
      server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx
  7. 59
    7
      server/sonar-web/src/main/js/apps/overview/branches/__tests__/QualityGatePanelSection-test.tsx
  8. 25
    6
      server/sonar-web/src/main/js/apps/overview/components/QualityGateConditions.tsx
  9. 88
    0
      server/sonar-web/src/main/js/apps/overview/components/QualityGateSimplifiedCondition.tsx
  10. 89
    0
      server/sonar-web/src/main/js/apps/overview/components/ZeroNewIssuesSimplificationGuide.tsx
  11. 67
    0
      server/sonar-web/src/main/js/apps/overview/components/__tests__/QualityGateSimplifiedCondition-test.tsx
  12. 31
    9
      server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx
  13. 74
    13
      server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx
  14. 30
    5
      server/sonar-web/src/main/js/apps/quality-gates/components/CaYCConditionsSimplificationGuide.tsx
  15. 26
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx
  16. 1
    0
      server/sonar-web/src/main/js/types/users.ts
  17. 30
    44
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java
  18. 7
    7
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java
  19. 13
    4
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 8
- 5
server/sonar-web/design-system/src/components/SpotlightTour.tsx View File

@@ -139,11 +139,14 @@ function TooltipComponent({
</div>
<div>{step.content}</div>
<div className="sw-flex sw-justify-between sw-items-center sw-mt-3">
<strong>
{stepXofYLabel
? stepXofYLabel(index + 1, size)
: intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })}
</strong>
{(stepXofYLabel || size > 1) && (
<strong>
{stepXofYLabel
? stepXofYLabel(index + 1, size)
: intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })}
</strong>
)}
<span />
<div>
{index > 0 && (
<ButtonLink className="sw-mr-4" {...backProps}>

+ 25
- 7
server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx View File

@@ -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<SpotlightTourProps> = {}) {
return renderWithContext(
<div>
@@ -149,6 +167,6 @@ function renderSpotlightTour(props: Partial<SpotlightTourProps> = {}) {
]}
{...props}
/>
</div>
</div>,
);
}

+ 12
- 2
server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardNumber.tsx View File

@@ -32,13 +32,22 @@ interface Props {
value: string;
failingConditionMetric: MetricKey;
requireLabel: string;
guidingKeyOnError?: string;
}

export default function MeasuresCardNumber(
props: React.PropsWithChildren<Props & React.HTMLAttributes<HTMLDivElement>>,
) {
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 && <TextError className="sw-font-regular sw-mt-2" text={requireLabel} />}

+ 1
- 0
server/sonar-web/src/main/js/apps/overview/branches/MeasuresCardPanel.tsx View File

@@ -72,6 +72,7 @@ export default function MeasuresCardPanel(props: React.PropsWithChildren<Props>)
count: newViolations,
},
)}
guidingKeyOnError="overviewZeroNewIssuesSimplification"
/>

<MeasuresCardNumber

+ 1
- 0
server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanel.tsx View File

@@ -98,6 +98,7 @@ export function QualityGatePanel(props: QualityGatePanelProps) {
isLastStatus={qgStatusIdx === failedQgStatuses.length - 1}
key={qgStatus.key}
qgStatus={qgStatus}
qualityGate={qualityGate}
/>
))}
</div>

+ 8
- 1
server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx View File

@@ -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 && (
<ZeroNewIssuesSimplificationGuide qualityGate={qualityGate} />
)}
<QualityGateConditions
component={qgStatus}
branchLike={qgStatus.branchLike}
failedConditions={newCodeFailedConditions}
isBuiltInQualityGate={qualityGate?.isBuiltIn}
/>
</>
)}

+ 59
- 7
server/sonar-web/src/main/js/apps/overview/branches/__tests__/QualityGatePanelSection-test.tsx View File

@@ -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<QualityGatePanelSectionProps> = {}) {
return renderComponent(<QualityGatePanelSection isApplication qgStatus={qgStatus} {...props} />);
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<QualityGatePanelSectionProps> = {},
currentUser: CurrentUser = mockLoggedInUser(),
) {
return renderComponent(
<CurrentUserContextProvider currentUser={currentUser}>
<QualityGatePanelSection isApplication qgStatus={qgStatus} {...props} />
</CurrentUserContextProvider>,
);
}

+ 25
- 6
server/sonar-web/src/main/js/apps/overview/components/QualityGateConditions.tsx View File

@@ -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<Component, 'key'>;
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) {
<ul id="overview-quality-gate-conditions-list" className="sw-mb-2">
{renderConditions.map((condition) => (
<div key={condition.measure.metric.key}>
<QualityGateCondition
branchLike={branchLike}
component={component}
condition={condition}
/>
{isSimplifiedCondition(condition) ? (
<QualityGateSimplifiedCondition
branchLike={branchLike}
component={component}
condition={condition}
/>
) : (
<QualityGateCondition
branchLike={branchLike}
component={component}
condition={condition}
/>
)}
<BasicSeparator />
</div>
))}

+ 88
- 0
server/sonar-web/src/main/js/apps/overview/components/QualityGateSimplifiedCondition.tsx View File

@@ -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<Component, 'key'>;
condition: QualityGateStatusConditionEnhanced;
}

export default function QualityGateSimplifiedCondition({
branchLike,
component,
condition,
}: Readonly<Props>) {
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 (
<LinkBox
to={getComponentIssuesUrl(component.key, {
...propsToIssueParams(condition.measure.metric.key, condition.period != null),
...getBranchLikeQuery(branchLike),
})}
>
<div className="sw-flex sw-p-2 sw-items-baseline">
<Highlight className="sw-mx-4 sw-w-6 sw-my-0 sw-text-right">{formattedValue}</Highlight>
<Highlight
className="sw-text-ellipsis sw-pr-4"
data-guiding-id={
metric.key === MetricKey.new_violations
? 'overviewZeroNewIssuesSimplification'
: undefined
}
>
{getPrimaryText()}
</Highlight>
</div>
</LinkBox>
);
}

+ 89
- 0
server/sonar-web/src/main/js/apps/overview/components/ZeroNewIssuesSimplificationGuide.tsx View File

@@ -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<Props>) {
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: (
<>
<p className="sw-mb-4">
<FormattedMessage
id="overview.quality_gates.conditions.condition_simplification_tour.content1"
defaultMessage={translate(
'overview.quality_gates.conditions.condition_simplification_tour.content1',
)}
values={{
link: (
<Link to={`/quality_gates/show/${qualityGate.name}`}>
{translateWithParameters(
'overview.quality_gates.conditions.condition_simplification_tour.content1.link',
qualityGate.name,
)}
</Link>
),
}}
/>
</p>
<p>
{translate('overview.quality_gates.conditions.condition_simplification_tour.content2')}
</p>
</>
),
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 (
<SpotlightTour
run={shouldRun}
closeLabel={translate('dismiss')}
callback={onCallback}
steps={steps}
/>
);
}

+ 67
- 0
server/sonar-web/src/main/js/apps/overview/components/__tests__/QualityGateSimplifiedCondition-test.tsx View File

@@ -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<QualityGateCondition['props']>) {
return renderComponent(
<QualityGateSimplifiedCondition
component={{ key: 'abcd-key' }}
condition={mockQualityGateStatusConditionEnhanced()}
{...props}
/>,
);
}

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 } : {}),
});
}

+ 31
- 9
server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx View File

@@ -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<MeasureEnhanced[]>([]);
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<QualityGate>();
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 (
<CenteredLayout>
<div className="sw-p-6">
@@ -119,6 +139,8 @@ export default function PullRequestOverview(props: Props) {
measures={measures}
/>

{qualityGate?.isBuiltIn && <ZeroNewIssuesSimplificationGuide qualityGate={qualityGate} />}

<SonarLintAd status={status} />
</div>
</div>

+ 74
- 13
server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx View File

@@ -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<ComponentPropsType<typeof PullRequestOverview>> = {},
currentUser = mockLoggedInUser(),
) {
renderComponent(
<CurrentUserContextProvider currentUser={mockLoggedInUser()}>
<CurrentUserContextProvider currentUser={currentUser}>
<PullRequestOverview
branchLike={mockPullRequest()}
component={mockComponent({

+ 30
- 5
server/sonar-web/src/main/js/apps/quality-gates/components/CaYCConditionsSimplificationGuide.tsx View File

@@ -22,6 +22,7 @@ import { SpotlightTour, SpotlightTourStep } from 'design-system';
import React from 'react';
import { dismissNotice } from '../../../api/users';
import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
import DocumentationLink from '../../../components/common/DocumentationLink';
import { translate } from '../../../helpers/l10n';
import { NoticeType } from '../../../types/users';

@@ -32,23 +33,46 @@ export default function CaYCConditionsSimplificationGuide() {
!currentUser.dismissedNotices[NoticeType.QG_CAYC_CONDITIONS_SIMPLIFICATION];

const steps: SpotlightTourStep[] = [
{
target: '[data-guiding-id="caycConditionsSimplification"]',
content: (
<p>{translate('quality_gates.cayc.condition_simplification_tour.page_1.content1')}</p>
),
title: translate('quality_gates.cayc.condition_simplification_tour.page_1.title'),
placement: 'right',
},
{
target: '[data-guiding-id="caycConditionsSimplification"]',
content: (
<>
<p className="sw-mb-4">
{translate('quality_gates.cayc.condition_simplification_tour.page_2.content1')}
</p>
<p>{translate('quality_gates.cayc.condition_simplification_tour.page_2.content2')}</p>
</>
),
title: translate('quality_gates.cayc.condition_simplification_tour.page_2.title'),
placement: 'right',
},
{
target: '[data-guiding-id="caycConditionsSimplification"]',
content: (
<>
<p className="sw-mb-4">
{translate('quality_gates.cayc.condition_simplification_tour.content1')}
{translate('quality_gates.cayc.condition_simplification_tour.page_3.content1')}
</p>
<p>{translate('quality_gates.cayc.condition_simplification_tour.content2')}</p>
<DocumentationLink to="/user-guide/issues/#resolutions">
{translate('quality_gates.cayc.condition_simplification_tour.page_3.content2')}
</DocumentationLink>
</>
),
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 (
<SpotlightTour
continuous
run={shouldRun}
closeLabel={translate('dismiss')}
callback={onCallback}

+ 26
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx View File

@@ -426,6 +426,32 @@ it('should display CaYC condition simplification tour for users who didnt dismis

expect(await byRole('alertdialog').find()).toBeInTheDocument();

expect(
byRole('alertdialog')
.byText('quality_gates.cayc.condition_simplification_tour.page_1.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_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());
});

+ 1
- 0
server/sonar-web/src/main/js/types/users.ts View File

@@ -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 {

+ 30
- 44
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java View File

@@ -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> 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> 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> 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> 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);
}
}

+ 7
- 7
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java View File

@@ -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<String> AVAILABLE_NOTICE_KEYS = List.of(EDUCATION_PRINCIPLES, SONARLINT_AD, ISSUE_CLEAN_CODE_GUIDE, QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION);
protected static final List<String> 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();
}
}

+ 13
- 4
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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}

Loading…
Cancel
Save