Browse Source

SONAR-21799 Measures page reflects software qualities

tags/10.5.0.89998
Ismail Cherri 1 month ago
parent
commit
01170ab5bd

+ 51
- 2
server/sonar-web/src/main/js/api/mocks/data/measures.ts View File

@@ -28,6 +28,7 @@ import { ComponentTree } from './components';
import { IssueData } from './issues';
import { listAllComponent, listAllComponentTrees } from './utils';

const MAX_RATING = 5;
export type MeasureRecords = Record<string, Record<string, Measure>>;

export function mockFullMeasureData(tree: ComponentTree, issueList: IssueData[]) {
@@ -68,6 +69,21 @@ function mockComponentMeasure(tree: ComponentTree, issueList: IssueData[], metri
}),
});

case MetricKey.new_security_issues:
return mockMeasure({
metric: metricKey,
period: {
index: 1,
value: JSON.stringify({
total: 3,
[SoftwareImpactSeverity.High]: 2,
[SoftwareImpactSeverity.Medium]: 0,
[SoftwareImpactSeverity.Low]: 1,
}),
},
value: undefined,
});

case MetricKey.reliability_issues:
return mockMeasure({
metric: metricKey,
@@ -79,6 +95,21 @@ function mockComponentMeasure(tree: ComponentTree, issueList: IssueData[], metri
}),
});

case MetricKey.new_reliability_issues:
return mockMeasure({
metric: metricKey,
period: {
index: 1,
value: JSON.stringify({
total: 2,
[SoftwareImpactSeverity.High]: 0,
[SoftwareImpactSeverity.Medium]: 1,
[SoftwareImpactSeverity.Low]: 1,
}),
},
value: undefined,
});

case MetricKey.maintainability_issues:
return mockMeasure({
metric: metricKey,
@@ -89,6 +120,21 @@ function mockComponentMeasure(tree: ComponentTree, issueList: IssueData[], metri
[SoftwareImpactSeverity.Low]: 1,
}),
});

case MetricKey.new_maintainability_issues:
return mockMeasure({
metric: metricKey,
period: {
index: 1,
value: JSON.stringify({
total: 5,
[SoftwareImpactSeverity.High]: 2,
[SoftwareImpactSeverity.Medium]: 2,
[SoftwareImpactSeverity.Low]: 1,
}),
},
value: undefined,
});
}

const issues = issueList
@@ -234,13 +280,16 @@ function mockComponentMeasure(tree: ComponentTree, issueList: IssueData[], metri
export function getMetricTypeFromKey(metricKey: string) {
if (/(coverage|duplication)$/.test(metricKey)) {
return MetricType.Percent;
} else if (/_rating$/.test(metricKey)) {
} else if (metricKey.includes('_rating')) {
return MetricType.Rating;
} else if (
[
MetricKey.reliability_issues,
MetricKey.new_reliability_issues,
MetricKey.security_issues,
MetricKey.new_security_issues,
MetricKey.maintainability_issues,
MetricKey.new_maintainability_issues,
].includes(metricKey as MetricKey)
) {
return MetricType.Data;
@@ -276,7 +325,7 @@ function isIssueRelatedRating(metricKey: MetricKey) {
* ratio to the LOC. But using the number will suffice as an approximation in our tests.
*/
function computeRating(issues: RawIssue[], type: IssueType) {
const value = Math.max(Math.min(issues.filter((i) => i.type === type).length, 5), 1);
const value = Math.max(Math.min(issues.filter((i) => i.type === type).length, MAX_RATING), 1);
return {
value: `${value}.0`,
bestValue: value === 1,

+ 85
- 15
server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx View File

@@ -88,11 +88,11 @@ describe('rendering', () => {
// Check one of the domains.
await user.click(ui.maintainabilityDomainBtn.get());
[
'New Code Smells 8',
'component_measures.metric.new_maintainability_issues.name 5',
'Added Technical Debt work_duration.x_minutes.1',
'Technical Debt Ratio on New Code 1.0%',
'Maintainability Rating on New Code metric.has_rating_X.E',
'Code Smells 8',
'component_measures.metric.maintainability_issues.name 2',
'Technical Debt work_duration.x_minutes.1',
'Technical Debt Ratio 1.0%',
'Maintainability Rating metric.has_rating_X.E',
@@ -102,6 +102,32 @@ describe('rendering', () => {
});
});

it('should correctly revert to old measures when analysis is missing', async () => {
measuresHandler.deleteComponentMeasure('foo', MetricKey.maintainability_issues);
measuresHandler.deleteComponentMeasure('foo', MetricKey.new_maintainability_issues);

const { ui, user } = getPageObject();
renderMeasuresApp();
await ui.appLoaded();

// Check one of the domains.
await user.click(ui.maintainabilityDomainBtn.get());
[
'component_measures.metric.new_code_smells.name 8',
'Added Technical Debt work_duration.x_minutes.1',
'Technical Debt Ratio on New Code 1.0%',
'Maintainability Rating on New Code metric.has_rating_X.E',
'component_measures.metric.code_smells.name 8',
'Technical Debt work_duration.x_minutes.1',
'Technical Debt Ratio 1.0%',
'Maintainability Rating metric.has_rating_X.E',
'Effort to Reach Maintainability Rating A work_duration.x_minutes.1',
].forEach((measure) => {
expect(ui.measureBtn(measure).get()).toBeInTheDocument();
});
expect(screen.getByText('overview.missing_project_data.TRK')).toBeInTheDocument();
});

it('should correctly render a list view', async () => {
const { ui } = getPageObject();
renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=list');
@@ -172,7 +198,17 @@ describe('rendering', () => {
renderMeasuresApp('component_measures?id=foo&metric=open_issues');
await ui.appLoaded();

expect(screen.getAllByText('Issues').length).toBeGreaterThan(1);
expect(screen.getAllByText('Issues').length).toEqual(1);
[
'component_measures.metric.new_violations.name 1',
'component_measures.metric.violations.name 1',
'component_measures.metric.confirmed_issues.name 1',
'component_measures.metric.accepted_issues.name 1',
'component_measures.metric.new_accepted_issues.name 1',
'component_measures.metric.false_positive_issues.name 1',
].forEach((measure) => {
expect(ui.measureBtn(measure).get()).toBeInTheDocument();
});
});

it('should render correctly if there are no measures', async () => {
@@ -267,14 +303,16 @@ describe('rendering', () => {

it('should correctly render a link to the activity page', async () => {
const { ui, user } = getPageObject();
renderMeasuresApp('component_measures?id=foo&metric=new_code_smells');
renderMeasuresApp('component_measures?id=foo&metric=new_maintainability_issues');
await ui.appLoaded();

expect(ui.goToActivityLink.query()).not.toBeInTheDocument();
await user.click(ui.measureBtn('Code Smells 8').get());
await user.click(
ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(),
);
expect(ui.goToActivityLink.get()).toHaveAttribute(
'href',
'/project/activity?id=foo&graph=custom&custom_metrics=code_smells',
'/project/activity?id=foo&graph=custom&custom_metrics=maintainability_issues',
);
});

@@ -305,9 +343,11 @@ describe('navigation', () => {
// Drilldown to the file level.
await user.click(ui.maintainabilityDomainBtn.get());

await user.click(ui.measureBtn('Code Smells 8').get());
await user.click(
ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(),
);
expect(
within(ui.measuresRow('folderA').get()).getByRole('cell', { name: '3' }),
within(ui.measuresRow('folderA').get()).getByRole('cell', { name: '2' }),
).toBeInTheDocument();
expect(
within(ui.measuresRow('test1.js').get()).getByRole('cell', { name: '2' }),
@@ -315,7 +355,7 @@ describe('navigation', () => {

await user.click(ui.fileLink('folderA').get());
expect(
within(ui.measuresRow('out.tsx').get()).getByRole('cell', { name: '1' }),
within(ui.measuresRow('out.tsx').get()).getByRole('cell', { name: '2' }),
).toBeInTheDocument();
expect(
within(ui.measuresRow('in.tsx').get()).getByRole('cell', { name: '2' }),
@@ -336,11 +376,13 @@ describe('navigation', () => {
await ui.appLoaded();

await user.click(ui.maintainabilityDomainBtn.get());
await user.click(ui.measureBtn('Code Smells 8').get());
await user.click(
ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(),
);
await waitFor(() => ui.changeViewToList());

expect(
within(await ui.measuresRow('out.tsx').find()).getByRole('cell', { name: '1' }),
within(await ui.measuresRow('out.tsx').find()).getByRole('cell', { name: '2' }),
).toBeInTheDocument();
expect(
within(ui.measuresRow('test1.js').get()).getByRole('cell', { name: '2' }),
@@ -378,13 +420,15 @@ describe('navigation', () => {

// Drilldown to the file level.
await user.click(ui.maintainabilityDomainBtn.get());
await user.click(ui.measureBtn('Code Smells 8').get());
await user.click(
ui.measureBtn('component_measures.metric.maintainability_issues.name 2').get(),
);

await ui.arrowDown(); // Select the 1st element ("folderA")
await ui.arrowRight(); // Open "folderA"

expect(
within(ui.measuresRow('out.tsx').get()).getByRole('cell', { name: '1' }),
within(ui.measuresRow('out.tsx').get()).getByRole('cell', { name: '2' }),
).toBeInTheDocument();
expect(
within(ui.measuresRow('in.tsx').get()).getByRole('cell', { name: '2' }),
@@ -394,7 +438,7 @@ describe('navigation', () => {
await ui.arrowLeft(); // Close "folderA"

expect(
within(ui.measuresRow('folderA').get()).getByRole('cell', { name: '3' }),
within(ui.measuresRow('folderA').get()).getByRole('cell', { name: '2' }),
).toBeInTheDocument();

await ui.arrowRight(); // Open "folderA"
@@ -416,18 +460,44 @@ describe('redirects', () => {
});

it('should redirect old metric route', async () => {
measuresHandler.deleteComponentMeasure('foo', MetricKey.maintainability_issues);
measuresHandler.deleteComponentMeasure('foo', MetricKey.new_maintainability_issues);

const { ui } = getPageObject();
renderMeasuresApp('component_measures/metric/bugs?id=foo');
await ui.appLoaded();
expect(ui.measureBtn('Bugs 0').get()).toHaveAttribute('aria-current', 'true');
expect(ui.measureBtn('component_measures.metric.bugs.name 0').get()).toHaveAttribute(
'aria-current',
'true',
);
});

it('should redirect old metric route for software qualities', async () => {
const { ui } = getPageObject();
renderMeasuresApp('component_measures/metric/security_issues?id=foo');
await ui.appLoaded();
expect(ui.measureBtn('component_measures.metric.security_issues.name 1').get()).toHaveAttribute(
'aria-current',
'true',
);
});

it('should redirect old domain route', async () => {
measuresHandler.deleteComponentMeasure('foo', MetricKey.maintainability_issues);
measuresHandler.deleteComponentMeasure('foo', MetricKey.new_maintainability_issues);

const { ui } = getPageObject();
renderMeasuresApp('component_measures/domain/bugs?id=foo');
await ui.appLoaded();
expect(ui.reliabilityDomainBtn.get()).toHaveAttribute('aria-expanded', 'true');
});

it('should redirect old domain route for software qualities', async () => {
const { ui } = getPageObject();
renderMeasuresApp('component_measures/domain/reliability_issues?id=foo');
await ui.appLoaded();
expect(ui.reliabilityDomainBtn.get()).toHaveAttribute('aria-expanded', 'true');
});
});

it('should allow to load more components', async () => {

+ 20
- 3
server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx View File

@@ -21,6 +21,7 @@ import { withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Spinner } from '@sonarsource/echoes-react';
import {
FlagMessage,
LargeCenteredLayout,
Note,
PageContentFontWrapper,
@@ -33,12 +34,15 @@ import { Helmet } from 'react-helmet-async';
import { getMeasuresWithPeriod } from '../../../api/measures';
import { getAllMetrics } from '../../../api/metrics';
import { ComponentContext } from '../../../app/components/componentContext/ComponentContext';
import HelpTooltip from '../../../components/controls/HelpTooltip';
import Suggestions from '../../../components/embed-docs-modal/Suggestions';
import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
import { enhanceMeasure } from '../../../components/measure/utils';
import '../../../components/search-navigator.css';
import AnalysisMissingInfoMessage from '../../../components/shared/AnalysisMissingInfoMessage';
import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../../helpers/branch-like';
import { translate } from '../../../helpers/l10n';
import { areLeakAndOverallCCTMeasuresComputed } from '../../../helpers/measures';
import { WithBranchLikesProps, useBranchesQuery } from '../../../queries/branch';
import { ComponentQualifier, isPortfolioLike } from '../../../types/component';
import { MeasurePageView } from '../../../types/measures';
@@ -134,7 +138,10 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> {
fetchMeasures(metrics: State['metrics']) {
const { branchLike } = this.props;
const query = parseQuery(this.props.location.query);
const componentKey = query.selected || this.props.component.key;
const componentKey =
query.selected !== undefined && query.selected !== ''
? query.selected
: this.props.component.key;

const filteredKeys = getMeasuresPageMetricKeys(metrics, branchLike);

@@ -285,15 +292,25 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> {
{measures.length > 0 ? (
<div className="sw-grid sw-grid-cols-12 sw-w-full">
<Sidebar
canBrowseAllChildProjects={!!canBrowseAllChildProjects}
measures={measures}
qualifier={qualifier}
selectedMetric={metric ? metric.key : query.metric}
showFullMeasures={showFullMeasures}
updateQuery={this.updateQuery}
/>

<div className="sw-col-span-9 sw-ml-12">
{!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
<FlagMessage className="sw-mb-4 it__portfolio_warning" variant="warning">
{translate('component_measures.not_all_measures_are_shown')}
<HelpTooltip
className="sw-ml-2"
overlay={translate('component_measures.not_all_measures_are_shown.help')}
/>
</FlagMessage>
)}
{!areLeakAndOverallCCTMeasuresComputed(measures) && (
<AnalysisMissingInfoMessage className="sw-mb-4" qualifier={qualifier} />
)}
{this.renderContent(displayOverview, query, metric)}
</div>
</div>

+ 13
- 5
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx View File

@@ -29,7 +29,7 @@ import { getBranchLikeQuery, isSameBranchLike } from '../../../helpers/branch-li
import { getComponentMeasureUniqueKey } from '../../../helpers/component';
import { KeyboardKeys } from '../../../helpers/keycodes';
import { translate } from '../../../helpers/l10n';
import { isDiffMetric } from '../../../helpers/measures';
import { getCCTMeasureValue, isDiffMetric } from '../../../helpers/measures';
import { RequestData } from '../../../helpers/request';
import { isDefined } from '../../../helpers/types';
import { getProjectUrl } from '../../../helpers/urls';
@@ -94,8 +94,14 @@ export default class MeasureContent extends React.PureComponent<Props, State> {
}

componentDidUpdate(prevProps: Props) {
const prevComponentKey = prevProps.selected || prevProps.rootComponent.key;
const componentKey = this.props.selected || this.props.rootComponent.key;
const prevComponentKey =
prevProps.selected !== undefined && prevProps.selected !== ''
? prevProps.selected
: prevProps.rootComponent.key;
const componentKey =
this.props.selected !== undefined && this.props.selected !== ''
? this.props.selected
: this.props.rootComponent.key;
if (
prevComponentKey !== componentKey ||
!isSameBranchLike(prevProps.branchLike, this.props.branchLike) ||
@@ -116,7 +122,7 @@ export default class MeasureContent extends React.PureComponent<Props, State> {
const { metricKeys, opts, strategy } = this.getComponentRequestParams(view, requestedMetric, {
...(asc !== undefined && { asc }),
});
const componentKey = selected || rootComponent.key;
const componentKey = selected !== undefined && selected !== '' ? selected : rootComponent.key;
const baseComponentMetrics = [requestedMetric.key];
if (requestedMetric.key === MetricKey.ncloc) {
baseComponentMetrics.push(MetricKey.ncloc_language_distribution);
@@ -347,8 +353,10 @@ export default class MeasureContent extends React.PureComponent<Props, State> {
return null;
}

const measureValue =
const rawMeasureValue =
measure && (isDiffMetric(measure.metric) ? measure.period?.value : measure.value);
const measureValue = getCCTMeasureValue(metric.key, rawMeasureValue);

const isFileComponent = isFile(baseComponent.qualifier);
const selectedIdx = this.getSelectedIndex();


+ 9
- 6
server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.tsx View File

@@ -17,8 +17,9 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { LinkStandalone } from '@sonarsource/echoes-react';
import classNames from 'classnames';
import { Link, MetricsLabel, MetricsRatingBadge } from 'design-system';
import { MetricsLabel, MetricsRatingBadge } from 'design-system';
import * as React from 'react';
import LanguageDistribution from '../../../components/charts/LanguageDistribution';
import Tooltip from '../../../components/controls/Tooltip';
@@ -30,7 +31,7 @@ import { BranchLike } from '../../../types/branch-like';
import { ComponentQualifier } from '../../../types/component';
import { MetricKey, MetricType } from '../../../types/metrics';
import { ComponentMeasure, Metric, Period, Measure as TypeMeasure } from '../../../types/types';
import { hasFullMeasures } from '../utils';
import { getMetricSubnavigationName, hasFullMeasures } from '../utils';
import LeakPeriodLegend from './LeakPeriodLegend';

interface Props {
@@ -42,7 +43,7 @@ interface Props {
secondaryMeasure?: TypeMeasure;
}

export default function MeasureHeader(props: Props) {
export default function MeasureHeader(props: Readonly<Props>) {
const { branchLike, component, leakPeriod, measureValue, metric, secondaryMeasure } = props;
const isDiff = isDiffMetric(metric.key);
const hasHistory =
@@ -53,11 +54,13 @@ export default function MeasureHeader(props: Props) {
ComponentQualifier.Project,
].includes(component.qualifier as ComponentQualifier) && hasFullMeasures(branchLike);
const displayLeak = hasFullMeasures(branchLike);
const title = getMetricSubnavigationName(metric, getLocalizedMetricName, isDiff);

return (
<div className="sw-mb-4">
<div className="sw-flex sw-items-center sw-justify-between sw-gap-4">
<div className="it__measure-details-metric sw-flex sw-items-center sw-gap-1">
<strong className="sw-body-md-highlight">{getLocalizedMetricName(metric)}</strong>
<strong className="sw-body-md-highlight">{title}</strong>

<div className="sw-flex sw-items-center sw-ml-2">
<Measure
@@ -84,12 +87,12 @@ export default function MeasureHeader(props: Props) {
{!isDiff && hasHistory && (
<Tooltip overlay={translate('component_measures.show_metric_history')}>
<span className="sw-ml-4">
<Link
<LinkStandalone
className="it__show-history-link sw-font-semibold"
to={getMeasureHistoryUrl(component.key, metric.key, branchLike)}
>
{translate('component_measures.see_metric_history')}
</Link>
</LinkStandalone>
</span>
</Tooltip>
)}

+ 32
- 30
server/sonar-web/src/main/js/apps/component-measures/config/domains.ts View File

@@ -23,16 +23,21 @@ interface Domains {
[domain: string]: { categories?: string[]; order: string[] };
}

const NEW_CODE_CATEGORY = 'new_code_category';
const OVERALL_CATEGORY = 'overall_category';

export const domains: Domains = {
Reliability: {
categories: ['new_code_category', 'overall_category'],
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY],
order: [
'new_code_category',
NEW_CODE_CATEGORY,
MetricKey.new_reliability_issues,
MetricKey.new_bugs,
MetricKey.new_reliability_rating,
MetricKey.new_reliability_remediation_effort,

'overall_category',
OVERALL_CATEGORY,
MetricKey.reliability_issues,
MetricKey.bugs,
MetricKey.reliability_rating,
MetricKey.reliability_remediation_effort,
@@ -40,14 +45,16 @@ export const domains: Domains = {
},

Security: {
categories: ['new_code_category', 'overall_category'],
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY],
order: [
'new_code_category',
NEW_CODE_CATEGORY,
MetricKey.new_security_issues,
MetricKey.new_vulnerabilities,
MetricKey.new_security_rating,
MetricKey.new_security_remediation_effort,

'overall_category',
OVERALL_CATEGORY,
MetricKey.security_issues,
MetricKey.vulnerabilities,
MetricKey.security_rating,
MetricKey.security_remediation_effort,
@@ -55,14 +62,14 @@ export const domains: Domains = {
},

SecurityReview: {
categories: ['new_code_category', 'overall_category'],
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY],
order: [
'new_code_category',
NEW_CODE_CATEGORY,
MetricKey.new_security_hotspots,
MetricKey.new_security_review_rating,
MetricKey.new_security_hotspots_reviewed,

'overall_category',
OVERALL_CATEGORY,
MetricKey.security_hotspots,
MetricKey.security_review_rating,
MetricKey.security_hotspots_reviewed,
@@ -70,15 +77,17 @@ export const domains: Domains = {
},

Maintainability: {
categories: ['new_code_category', 'overall_category'],
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY],
order: [
'new_code_category',
NEW_CODE_CATEGORY,
MetricKey.new_maintainability_issues,
MetricKey.new_code_smells,
MetricKey.new_technical_debt,
MetricKey.new_sqale_debt_ratio,
MetricKey.new_maintainability_rating,

'overall_category',
OVERALL_CATEGORY,
MetricKey.maintainability_issues,
MetricKey.code_smells,
MetricKey.sqale_index,
MetricKey.sqale_debt_ratio,
@@ -88,9 +97,9 @@ export const domains: Domains = {
},

Coverage: {
categories: ['new_code_category', 'overall_category', 'tests_category'],
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY, 'tests_category'],
order: [
'new_code_category',
NEW_CODE_CATEGORY,
MetricKey.new_coverage,
MetricKey.new_lines_to_cover,
MetricKey.new_uncovered_lines,
@@ -99,7 +108,7 @@ export const domains: Domains = {
MetricKey.new_uncovered_conditions,
MetricKey.new_branch_coverage,

'overall_category',
OVERALL_CATEGORY,
MetricKey.coverage,
MetricKey.lines_to_cover,
MetricKey.uncovered_lines,
@@ -119,14 +128,14 @@ export const domains: Domains = {
},

Duplications: {
categories: ['new_code_category', 'overall_category'],
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY],
order: [
'new_code_category',
NEW_CODE_CATEGORY,
MetricKey.new_duplicated_lines_density,
MetricKey.new_duplicated_lines,
MetricKey.new_duplicated_blocks,

'overall_category',
OVERALL_CATEGORY,
MetricKey.duplicated_lines_density,
MetricKey.duplicated_lines,
MetricKey.duplicated_blocks,
@@ -157,23 +166,16 @@ export const domains: Domains = {
},

Issues: {
categories: [NEW_CODE_CATEGORY, OVERALL_CATEGORY],
order: [
NEW_CODE_CATEGORY,
MetricKey.new_violations,
MetricKey.new_blocker_violations,
MetricKey.new_critical_violations,
MetricKey.new_major_violations,
MetricKey.new_minor_violations,
MetricKey.new_info_violations,
MetricKey.new_accepted_issues,

OVERALL_CATEGORY,
MetricKey.violations,
MetricKey.blocker_violations,
MetricKey.critical_violations,
MetricKey.major_violations,
MetricKey.minor_violations,
MetricKey.info_violations,
MetricKey.open_issues,
MetricKey.reopened_issues,
MetricKey.confirmed_issues,
MetricKey.accepted_issues,
MetricKey.false_positive_issues,
],
},

+ 3
- 2
server/sonar-web/src/main/js/apps/component-measures/drilldown/MeasureCell.tsx View File

@@ -21,7 +21,7 @@ import { MetricsLabel, MetricsRatingBadge, NumericalCell } from 'design-system';
import * as React from 'react';
import Measure from '../../../components/measure/Measure';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { formatMeasure, isDiffMetric } from '../../../helpers/measures';
import { formatMeasure, getCCTMeasureValue, isDiffMetric } from '../../../helpers/measures';
import { MetricType } from '../../../types/metrics';
import { ComponentMeasureEnhanced, MeasureEnhanced, Metric } from '../../../types/types';

@@ -35,7 +35,8 @@ export default function MeasureCell({ component, measure, metric }: Props) {
const getValue = (item: { leak?: string; value?: string }) =>
isDiffMetric(metric.key) ? item.leak : item.value;

const value = getValue(measure || component);
const rawValue = getValue(measure || component);
const value = getCCTMeasureValue(metric.key, rawValue);

return (
<NumericalCell className="sw-py-3">

+ 8
- 3
server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx View File

@@ -34,7 +34,12 @@ import {
translate,
} from '../../../helpers/l10n';
import { MeasureEnhanced } from '../../../types/types';
import { addMeasureCategories, hasBubbleChart, sortMeasures } from '../utils';
import {
addMeasureCategories,
getMetricSubnavigationName,
hasBubbleChart,
sortMeasures,
} from '../utils';
import DomainSubnavigationItem from './DomainSubnavigationItem';

interface Props {
@@ -45,7 +50,7 @@ interface Props {
showFullMeasures: boolean;
}

export default function DomainSubnavigation(props: Props) {
export default function DomainSubnavigation(props: Readonly<Props>) {
const { domain, onChange, open, selected, showFullMeasures } = props;
const helperMessageKey = `component_measures.domain_subnavigation.${domain.name}.help`;
const helper = hasMessage(helperMessageKey) ? translate(helperMessageKey) : undefined;
@@ -100,7 +105,7 @@ export default function DomainSubnavigation(props: Props) {
<DomainSubnavigationItem
key={item.metric.key}
measure={item}
name={translateMetric(item.metric)}
name={getMetricSubnavigationName(item.metric, translateMetric)}
onChange={onChange}
selected={selected}
/>

+ 6
- 1
server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigationItem.tsx View File

@@ -29,7 +29,12 @@ interface Props {
selected: string;
}

export default function DomainSubnavigationItem({ measure, name, onChange, selected }: Props) {
export default function DomainSubnavigationItem({
measure,
name,
onChange,
selected,
}: Readonly<Props>) {
const { key } = measure.metric;
return (
<SubnavigationItem active={key === selected} key={key} onClick={onChange} value={key}>

+ 6
- 30
server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx View File

@@ -21,7 +21,6 @@ import { withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
BareButton,
FlagMessage,
LAYOUT_FOOTER_HEIGHT,
LAYOUT_GLOBAL_NAV_HEIGHT,
LAYOUT_PROJECT_NAV_HEIGHT,
@@ -32,33 +31,24 @@ import {
} from 'design-system';
import * as React from 'react';
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
import HelpTooltip from '../../../components/controls/HelpTooltip';
import { translate } from '../../../helpers/l10n';
import useFollowScroll from '../../../hooks/useFollowScroll';
import { isPortfolioLike } from '../../../types/component';
import { MeasureEnhanced } from '../../../types/types';
import { PROJECT_OVERVEW, Query, groupByDomains, isProjectOverview } from '../utils';
import { PROJECT_OVERVEW, Query, isProjectOverview, populateDomainsFromMeasures } from '../utils';
import DomainSubnavigation from './DomainSubnavigation';
import { Domain } from '../../../types/measures';

interface Props {
canBrowseAllChildProjects: boolean;
measures: MeasureEnhanced[];
qualifier: string;
selectedMetric: string;
showFullMeasures: boolean;
updateQuery: (query: Partial<Query>) => void;
}

export default function Sidebar(props: Props) {
const {
showFullMeasures,
canBrowseAllChildProjects,
qualifier,
updateQuery,
selectedMetric,
measures,
} = props;
export default function Sidebar(props: Readonly<Props>) {
const { showFullMeasures, updateQuery, selectedMetric, measures } = props;
const { top: topScroll, scrolledOnce } = useFollowScroll();
const domains = populateDomainsFromMeasures(measures);

const handleChangeMetric = React.useCallback(
(metric: string) => {
@@ -89,15 +79,6 @@ export default function Sidebar(props: Props) {
)`,
}}
>
{!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
<FlagMessage className="sw-mt-4 it__portfolio_warning" variant="warning">
{translate('component_measures.not_all_measures_are_shown')}
<HelpTooltip
className="sw-ml-2"
overlay={translate('component_measures.not_all_measures_are_shown.help')}
/>
</FlagMessage>
)}
<section
className="sw-flex sw-flex-col sw-gap-4 sw-p-4"
aria-label={translate('component_measures.navigation')}
@@ -118,7 +99,7 @@ export default function Sidebar(props: Props) {
</SubnavigationItem>
</SubnavigationGroup>

{groupByDomains(measures).map((domain: Domain) => (
{domains.map((domain: Domain) => (
<DomainSubnavigation
domain={domain}
key={domain.name}
@@ -133,11 +114,6 @@ export default function Sidebar(props: Props) {
);
}

interface Domain {
measures: MeasureEnhanced[];
name: string;
}

function isDomainSelected(selectedMetric: string, domain: Domain) {
return (
selectedMetric === domain.name ||

+ 1
- 1
server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx View File

@@ -29,7 +29,7 @@ interface Props {
measure: MeasureEnhanced;
}

export default function SubnavigationMeasureValue({ measure }: Props) {
export default function SubnavigationMeasureValue({ measure }: Readonly<Props>) {
const isDiff = isDiffMetric(measure.metric.key);
const value = isDiff ? measure.leak : measure.value;
const formatted = formatMeasure(value, MetricType.Rating);

+ 106
- 6
server/sonar-web/src/main/js/apps/component-measures/utils.ts View File

@@ -20,9 +20,22 @@
import { groupBy, memoize, sortBy, toPairs } from 'lodash';
import { enhanceMeasure } from '../../components/measure/utils';
import { isBranch, isPullRequest } from '../../helpers/branch-like';
import { HIDDEN_METRICS } from '../../helpers/constants';
import { getLocalizedMetricName } from '../../helpers/l10n';
import { MEASURES_REDIRECTION, getDisplayMetrics, isDiffMetric } from '../../helpers/measures';
import {
CCT_SOFTWARE_QUALITY_METRICS,
HIDDEN_METRICS,
LEAK_CCT_SOFTWARE_QUALITY_METRICS,
LEAK_OLD_TAXONOMY_METRICS,
OLD_TAXONOMY_METRICS,
} from '../../helpers/constants';
import { getLocalizedMetricName, translate } from '../../helpers/l10n';
import {
MEASURES_REDIRECTION,
areLeakCCTMeasuresComputed,
areCCTMeasuresComputed,
getDisplayMetrics,
isDiffMetric,
getCCTMeasureValue,
} from '../../helpers/measures';
import {
cleanQuery,
parseAsOptionalBoolean,
@@ -31,7 +44,7 @@ import {
} from '../../helpers/query';
import { BranchLike } from '../../types/branch-like';
import { ComponentQualifier } from '../../types/component';
import { MeasurePageView } from '../../types/measures';
import { Domain, MeasurePageView } from '../../types/measures';
import { MetricKey, MetricType } from '../../types/metrics';
import {
ComponentMeasure,
@@ -51,16 +64,103 @@ export const DEFAULT_VIEW = MeasurePageView.tree;
export const DEFAULT_METRIC = PROJECT_OVERVEW;
export const KNOWN_DOMAINS = [
'Releasability',
'Reliability',
'Security',
'SecurityReview',
'Reliability',
'Maintainability',
'SecurityReview',
'Coverage',
'Duplications',
'Size',
'Complexity',
];

const CCT_METRIC_DOMAIN_MAP: Dict<string> = {
[MetricKey.security_issues]: 'Security',
[MetricKey.new_security_issues]: 'Security',
[MetricKey.reliability_issues]: 'Reliability',
[MetricKey.new_reliability_issues]: 'Reliability',
[MetricKey.maintainability_issues]: 'Maintainability',
[MetricKey.new_maintainability_issues]: 'Maintainability',
};

const DEPRECATED_METRICS = [
MetricKey.blocker_violations,
MetricKey.new_blocker_violations,
MetricKey.critical_violations,
MetricKey.new_critical_violations,
MetricKey.major_violations,
MetricKey.new_major_violations,
MetricKey.info_violations,
MetricKey.new_info_violations,
MetricKey.minor_violations,
MetricKey.new_minor_violations,
MetricKey.high_impact_accepted_issues,
];

const ISSUES_METRICS = [
MetricKey.accepted_issues,
MetricKey.new_accepted_issues,
MetricKey.confirmed_issues,
MetricKey.false_positive_issues,
MetricKey.violations,
MetricKey.new_violations,
];

export const populateDomainsFromMeasures = memoize((measures: MeasureEnhanced[]): Domain[] => {
let populatedMeasures = measures
.filter((measure) => !DEPRECATED_METRICS.includes(measure.metric.key as MetricKey))
.map((measure) => {
const isDiff = isDiffMetric(measure.metric.key);
const calculatedValue = getCCTMeasureValue(
measure.metric.key,
isDiff ? measure.leak : measure.value,
);

return {
...measure,
metric: {
...measure.metric,
domain: CCT_METRIC_DOMAIN_MAP[measure.metric.key] ?? measure.metric.domain,
},
...(!isDiff && { value: calculatedValue }),
...(isDiff && { leak: calculatedValue }),
};
});
if (areLeakCCTMeasuresComputed(measures)) {
populatedMeasures = populatedMeasures.filter(
(measure) => !LEAK_OLD_TAXONOMY_METRICS.includes(measure.metric.key as MetricKey),
);
}
if (areCCTMeasuresComputed(measures)) {
populatedMeasures = populatedMeasures.filter(
(measure) => !OLD_TAXONOMY_METRICS.includes(measure.metric.key as MetricKey),
);
}

return groupByDomains(populatedMeasures);
});

export function getMetricSubnavigationName(
metric: Metric,
translateFn: (metric: Metric) => string,
isDiff = false,
) {
if (
[
...LEAK_CCT_SOFTWARE_QUALITY_METRICS,
...CCT_SOFTWARE_QUALITY_METRICS,
...ISSUES_METRICS,
...OLD_TAXONOMY_METRICS,
...LEAK_OLD_TAXONOMY_METRICS,
].includes(metric.key as MetricKey)
) {
return translate(
`component_measures.metric.${metric.key}.${isDiff ? 'detailed_name' : 'name'}`,
);
}
return translateFn(metric);
}

export function filterMeasures(measures: MeasureEnhanced[]): MeasureEnhanced[] {
return measures.filter((measure) => !HIDDEN_METRICS.includes(measure.metric.key as MetricKey));
}

+ 12
- 0
server/sonar-web/src/main/js/helpers/constants.ts View File

@@ -92,12 +92,24 @@ export const CCT_SOFTWARE_QUALITY_METRICS = [
MetricKey.maintainability_issues,
];

export const LEAK_CCT_SOFTWARE_QUALITY_METRICS = [
MetricKey.new_security_issues,
MetricKey.new_reliability_issues,
MetricKey.new_maintainability_issues,
];

export const OLD_TAXONOMY_METRICS = [
MetricKey.vulnerabilities,
MetricKey.bugs,
MetricKey.code_smells,
];

export const LEAK_OLD_TAXONOMY_METRICS = [
MetricKey.new_vulnerabilities,
MetricKey.new_bugs,
MetricKey.new_code_smells,
];

export const OLD_TO_NEW_TAXONOMY_METRICS_MAP: { [key in MetricKey]?: MetricKey } = {
[MetricKey.vulnerabilities]: MetricKey.security_issues,
[MetricKey.bugs]: MetricKey.reliability_issues,

+ 37
- 2
server/sonar-web/src/main/js/helpers/measures.ts View File

@@ -23,7 +23,11 @@ import {
QualityGateStatusConditionEnhanced,
} from '../types/quality-gates';
import { Dict, Measure, MeasureEnhanced, Metric } from '../types/types';
import { CCT_SOFTWARE_QUALITY_METRICS, ONE_SECOND } from './constants';
import {
CCT_SOFTWARE_QUALITY_METRICS,
LEAK_CCT_SOFTWARE_QUALITY_METRICS,
ONE_SECOND,
} from './constants';
import { translate, translateWithParameters } from './l10n';
import { getCurrentLocale } from './l10nBundle';
import { isDefined } from './types';
@@ -72,13 +76,28 @@ export function isDiffMetric(metricKey: MetricKey | string): boolean {
}

export function getDisplayMetrics(metrics: Metric[]) {
return metrics.filter((metric) => !metric.hidden && !['DATA', 'DISTRIB'].includes(metric.type));
return metrics.filter(
(metric) =>
!metric.hidden &&
([...CCT_SOFTWARE_QUALITY_METRICS, ...LEAK_CCT_SOFTWARE_QUALITY_METRICS].includes(
metric.key as MetricKey,
) ||
![MetricType.Data, MetricType.Distribution].includes(metric.type as MetricType)),
);
}

export function findMeasure(measures: MeasureEnhanced[], metric: MetricKey | string) {
return measures.find((measure) => measure.metric.key === metric);
}

export function areLeakCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[]) {
return LEAK_CCT_SOFTWARE_QUALITY_METRICS.every((metric) =>
measures?.find((measure) =>
isMeasureEnhanced(measure) ? measure.metric.key === metric : measure.metric === metric,
),
);
}

export function areCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[]) {
return CCT_SOFTWARE_QUALITY_METRICS.every((metric) =>
measures?.find((measure) =>
@@ -87,10 +106,26 @@ export function areCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[])
);
}

export function areLeakAndOverallCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[]) {
return areLeakCCTMeasuresComputed(measures) && areCCTMeasuresComputed(measures);
}

function isMeasureEnhanced(measure: Measure | MeasureEnhanced): measure is MeasureEnhanced {
return (measure.metric as Metric)?.key !== undefined;
}

export const getCCTMeasureValue = (key: string, value?: string) => {
if (
CCT_SOFTWARE_QUALITY_METRICS.concat(LEAK_CCT_SOFTWARE_QUALITY_METRICS).includes(
key as MetricKey,
) &&
value !== undefined
) {
return JSON.parse(value).total;
}
return value;
};

const HOURS_IN_DAY = 8;

type Formatter = (value: string | number, options?: Dict<unknown>) => string;

+ 49
- 9
server/sonar-web/src/main/js/helpers/mocks/metrics.ts View File

@@ -912,11 +912,20 @@ export const DEFAULT_METRICS: Dict<Metric> = {
},
reliability_issues: {
key: 'reliability_issues',
type: 'INT',
type: 'DATA',
name: 'Reliability',
description: 'Reliability issues',
direction: -1,
qualitative: true,
direction: 0,
qualitative: false,
hidden: false,
},
new_reliability_issues: {
key: 'new_reliability_issues',
type: 'DATA',
name: 'New Reliability',
description: 'New Reliability issues',
direction: 0,
qualitative: false,
hidden: false,
},
reliability_rating: {
@@ -1023,11 +1032,21 @@ export const DEFAULT_METRICS: Dict<Metric> = {
},
security_issues: {
key: 'security_issues',
type: 'INT',
type: 'DATA',
name: 'Security',
description: 'Security issues',
direction: -1,
qualitative: true,
direction: 0,
qualitative: false,
hidden: false,
},
new_security_issues: {
key: 'new_security_issues',
type: 'DATA',
name: 'Security',
description: 'New Security issues',
domain: 'Issues',
direction: 0,
qualitative: false,
hidden: false,
},
security_rating: {
@@ -1192,11 +1211,22 @@ export const DEFAULT_METRICS: Dict<Metric> = {
},
maintainability_issues: {
key: 'maintainability_issues',
type: 'INT',
type: 'DATA',
name: 'Maintainability',
description: 'Maintainability issues',
direction: -1,
qualitative: true,
domain: 'Issues',
direction: 0,
qualitative: false,
hidden: false,
},
new_maintainability_issues: {
key: 'new_maintainability_issues',
type: 'DATA',
name: 'Maintainability',
description: 'New Maintainability issues',
domain: 'Issues',
direction: 0,
qualitative: false,
hidden: false,
},
sqale_index: {
@@ -1389,4 +1419,14 @@ export const DEFAULT_METRICS: Dict<Metric> = {
qualitative: false,
hidden: false,
},
new_accepted_issues: {
key: 'new_accepted_issues',
type: 'INT',
name: 'New Accepted Issues',
description: 'New Accepted issues',
domain: 'Issues',
direction: -1,
qualitative: false,
hidden: false,
},
};

+ 6
- 1
server/sonar-web/src/main/js/types/measures.ts View File

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ComponentMeasure, Metric, Period, PeriodMeasure } from './types';
import { ComponentMeasure, MeasureEnhanced, Metric, Period, PeriodMeasure } from './types';

export interface MeasuresForProjects {
component: string;
@@ -41,3 +41,8 @@ export enum MeasurePageView {
tree = 'tree',
treemap = 'treemap',
}

export interface Domain {
measures: MeasureEnhanced[];
name: string;
}

+ 3
- 0
server/sonar-web/src/main/js/types/metrics.ts View File

@@ -93,15 +93,18 @@ export enum MetricKey {
new_line_coverage = 'new_line_coverage',
new_lines = 'new_lines',
new_lines_to_cover = 'new_lines_to_cover',
new_maintainability_issues = 'new_maintainability_issues',
new_maintainability_rating = 'new_maintainability_rating',
new_maintainability_rating_distribution = 'new_maintainability_rating_distribution',
new_major_violations = 'new_major_violations',
new_minor_violations = 'new_minor_violations',
new_reliability_issues = 'new_reliability_issues',
new_reliability_rating = 'new_reliability_rating',
new_reliability_remediation_effort = 'new_reliability_remediation_effort',
new_reliability_rating_distribution = 'new_reliability_rating_distribution',
new_security_hotspots = 'new_security_hotspots',
new_security_hotspots_reviewed = 'new_security_hotspots_reviewed',
new_security_issues = 'new_security_issues',
new_security_rating = 'new_security_rating',
new_security_rating_distribution = 'new_security_rating_distribution',
new_security_remediation_effort = 'new_security_remediation_effort',

+ 27
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -4180,6 +4180,33 @@ component_measures.bubble_chart.zoom_level=Current zoom level. Scroll on the cha
component_measures.not_all_measures_are_shown=Not all projects and applications are included
component_measures.not_all_measures_are_shown.help=You do not have access to all projects and/or applications. Measures are still computed based on all projects and applications.

component_measures.metric.new_security_issues.name=Issues
component_measures.metric.new_security_issues.detailed_name=New Issues
component_measures.metric.new_vulnerabilities.name=Issues
component_measures.metric.new_vulnerabilities.detailed_name=New Issues
component_measures.metric.new_reliability_issues.name=Issues
component_measures.metric.new_reliability_issues.detailed_name=New Issues
component_measures.metric.new_maintainability_issues.name=Issues
component_measures.metric.new_maintainability_issues.detailed_name=New Issues
component_measures.metric.new_code_smells.name=Issues
component_measures.metric.new_code_smells.detailed_name=New Issues
component_measures.metric.new_violations.name=Open Issues
component_measures.metric.new_violations.detailed_name=New Open Issues
component_measures.metric.new_accepted_issues.name=Accepted Issues
component_measures.metric.new_accepted_issues.detailed_name=New Accepted Issues
component_measures.metric.new_bugs.name=Issues
component_measures.metric.new_bugs.detailed_name=New Issues
component_measures.metric.security_issues.name=Issues
component_measures.metric.vulnerabilities.name=Issues
component_measures.metric.reliability_issues.name=Issues
component_measures.metric.bugs.name=Issues
component_measures.metric.maintainability_issues.name=Issues
component_measures.metric.code_smells.name=Issues
component_measures.metric.violations.name=Open Issues
component_measures.metric.accepted_issues.name=Accepted Issues
component_measures.metric.confirmed_issues.name=Confirmed Issues
component_measures.metric.false_positive_issues.name=False Positive Issues

#------------------------------------------------------------------------------
#
# DOCS

Loading…
Cancel
Save