diff options
author | stanislavh <stanislav.honcharov@sonarsource.com> | 2024-03-08 10:51:56 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-03-08 20:02:35 +0000 |
commit | d421d0e24287af84af0dd0399574ad2a7bf06d14 (patch) | |
tree | dd05ff5482c0caf2ca2658d4d12dc526384cce84 /server/sonar-web/src/main/js | |
parent | 92f48cb48753902d5356deb57c3b13f2a1c59370 (diff) | |
download | sonarqube-d421d0e24287af84af0dd0399574ad2a7bf06d14.tar.gz sonarqube-d421d0e24287af84af0dd0399574ad2a7bf06d14.zip |
SONAR-21768 Projects/code and app/projects pages adopt the new taxonomy
Diffstat (limited to 'server/sonar-web/src/main/js')
16 files changed, 516 insertions, 214 deletions
diff --git a/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts b/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts index 857a4515d2e..83d30d441fa 100644 --- a/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts +++ b/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts @@ -20,10 +20,11 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; -import { keyBy, times } from 'lodash'; +import { keyBy, omit, times } from 'lodash'; import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; +import { CCT_SOFTWARE_QUALITY_METRICS } from '../../../helpers/constants'; import { isDiffMetric } from '../../../helpers/measures'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockMeasure } from '../../../helpers/testMocks'; @@ -238,9 +239,9 @@ it('should correctly show measures for a project', async () => { const folderRow = ui.measureRow(/folderA/); [ [MetricKey.ncloc, '2'], - [MetricKey.bugs, '2'], - [MetricKey.vulnerabilities, '2'], - [MetricKey.code_smells, '2'], + [MetricKey.security_issues, '4'], + [MetricKey.reliability_issues, '4'], + [MetricKey.maintainability_issues, '4'], [MetricKey.security_hotspots, '2'], [MetricKey.coverage, '2.0%'], [MetricKey.duplicated_lines_density, '2.0%'], @@ -252,9 +253,73 @@ it('should correctly show measures for a project', async () => { const fileRow = ui.measureRow(/index\.tsx/); [ [MetricKey.ncloc, '—'], - [MetricKey.bugs, '—'], - [MetricKey.vulnerabilities, '—'], - [MetricKey.code_smells, '—'], + [MetricKey.security_issues, '—'], + [MetricKey.reliability_issues, '—'], + [MetricKey.maintainability_issues, '—'], + [MetricKey.security_hotspots, '—'], + [MetricKey.coverage, '—'], + [MetricKey.duplicated_lines_density, '—'], + ].forEach(([domain, value]) => { + expect(ui.measureValueCell(fileRow, domain, value)).toBeInTheDocument(); + }); +}); + +it('should correctly show measures for a project when relying on old taxonomy', async () => { + const component = mockComponent(componentsHandler.findComponentTree('foo')?.component); + componentsHandler.registerComponentTree({ + component, + ancestors: [], + children: [ + { + component: mockComponent({ + key: 'folderA', + name: 'folderA', + qualifier: ComponentQualifier.Directory, + }), + ancestors: [component], + children: [], + }, + { + component: mockComponent({ + key: 'index.tsx', + name: 'index.tsx', + qualifier: ComponentQualifier.File, + }), + ancestors: [component], + children: [], + }, + ], + }); + componentsHandler.registerComponentMeasures({ + foo: { [MetricKey.ncloc]: mockMeasure({ metric: MetricKey.ncloc }) }, + folderA: omit(generateMeasures('2.0'), CCT_SOFTWARE_QUALITY_METRICS), + 'index.tsx': {}, + }); + const ui = getPageObject(userEvent.setup()); + renderCode(); + await ui.appLoaded(component.name); + + // Folder A + const folderRow = ui.measureRow(/folderA/); + [ + [MetricKey.ncloc, '2'], + [MetricKey.security_issues, '2'], + [MetricKey.reliability_issues, '2'], + [MetricKey.maintainability_issues, '2'], + [MetricKey.security_hotspots, '2'], + [MetricKey.coverage, '2.0%'], + [MetricKey.duplicated_lines_density, '2.0%'], + ].forEach(([domain, value]) => { + expect(ui.measureValueCell(folderRow, domain, value)).toBeInTheDocument(); + }); + + // index.tsx + const fileRow = ui.measureRow(/index\.tsx/); + [ + [MetricKey.ncloc, '—'], + [MetricKey.security_issues, '—'], + [MetricKey.reliability_issues, '—'], + [MetricKey.maintainability_issues, '—'], [MetricKey.security_hotspots, '—'], [MetricKey.coverage, '—'], [MetricKey.duplicated_lines_density, '—'], @@ -445,6 +510,13 @@ function generateMeasures(overallValue = '1.0', newValue = '2.0') { return keyBy( [ ...[ + MetricKey.security_issues, + MetricKey.reliability_issues, + MetricKey.maintainability_issues, + ].map((metric) => + mockMeasure({ metric, value: JSON.stringify({ total: 4 }), period: undefined }), + ), + ...[ MetricKey.ncloc, MetricKey.new_lines, MetricKey.bugs, diff --git a/server/sonar-web/src/main/js/apps/code/__tests__/__snapshots__/utils-test.tsx.snap b/server/sonar-web/src/main/js/apps/code/__tests__/__snapshots__/utils-test.tsx.snap index f6289e4d45d..202da74f5ad 100644 --- a/server/sonar-web/src/main/js/apps/code/__tests__/__snapshots__/utils-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/code/__tests__/__snapshots__/utils-test.tsx.snap @@ -4,8 +4,11 @@ exports[`getCodeMetrics should return the right metrics for apps 1`] = ` [ "alert_status", "ncloc", - "bugs", + "security_issues", + "reliability_issues", + "maintainability_issues", "vulnerabilities", + "bugs", "code_smells", "security_hotspots", "coverage", @@ -75,8 +78,11 @@ exports[`getCodeMetrics should return the right metrics for portfolios 4`] = ` exports[`getCodeMetrics should return the right metrics for projects 1`] = ` [ "ncloc", - "bugs", + "security_issues", + "reliability_issues", + "maintainability_issues", "vulnerabilities", + "bugs", "code_smells", "security_hotspots", "coverage", @@ -87,8 +93,11 @@ exports[`getCodeMetrics should return the right metrics for projects 1`] = ` exports[`getCodeMetrics should return the right metrics for projects 2`] = ` [ "new_lines", - "bugs", + "security_issues", + "reliability_issues", + "maintainability_issues", "vulnerabilities", + "bugs", "code_smells", "security_hotspots", "new_coverage", diff --git a/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx b/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx index ea6dca7d68b..493b623f77c 100644 --- a/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx @@ -17,6 +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 { Spinner } from '@sonarsource/echoes-react'; import { Card, FlagMessage, @@ -24,9 +25,8 @@ import { KeyboardHint, LargeCenteredLayout, LightLabel, - Spinner, } from 'design-system'; -import { intersection } from 'lodash'; +import { difference, intersection } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; @@ -34,8 +34,11 @@ import HelpTooltip from '../../../components/controls/HelpTooltip'; import ListFooter from '../../../components/controls/ListFooter'; import Suggestions from '../../../components/embed-docs-modal/Suggestions'; import { Location } from '../../../components/hoc/withRouter'; +import AnalysisMissingInfoMessage from '../../../components/shared/AnalysisMissingInfoMessage'; +import { CCT_SOFTWARE_QUALITY_METRICS, OLD_TAXONOMY_METRICS } from '../../../helpers/constants'; import { KeyboardKeys } from '../../../helpers/keycodes'; import { translate } from '../../../helpers/l10n'; +import { areCCTMeasuresComputed } from '../../../helpers/measures'; import { BranchLike } from '../../../types/branch-like'; import { isApplication, isPortfolioLike } from '../../../types/component'; import { Breadcrumb, Component, ComponentMeasure, Dict, Metric } from '../../../types/types'; @@ -99,7 +102,15 @@ export default function CodeAppRenderer(props: Props) { getCodeMetrics(component.qualifier, branchLike, { newCode: newCodeSelected }), Object.keys(metrics), ); - const filteredMetrics = metricKeys.map((metric) => metrics[metric]); + + const allComponentsHaveSoftwareQualityMeasures = components.every((component) => + areCCTMeasuresComputed(component.measures), + ); + + const filteredMetrics = difference( + metricKeys, + allComponentsHaveSoftwareQualityMeasures ? OLD_TAXONOMY_METRICS : CCT_SOFTWARE_QUALITY_METRICS, + ).map((key) => metrics[key]); let defaultTitle = translate('code.page'); if (isApplication(baseComponent?.qualifier)) { @@ -129,6 +140,10 @@ export default function CodeAppRenderer(props: Props) { </FlagMessage> )} + {!allComponentsHaveSoftwareQualityMeasures && ( + <AnalysisMissingInfoMessage qualifier={component.qualifier} className="sw-mb-4" /> + )} + <div className="sw-flex sw-justify-between"> <div> {hasComponents && ( @@ -181,7 +196,7 @@ export default function CodeAppRenderer(props: Props) { {(showComponentList || showSearch) && ( <Card className="sw-mt-2 sw-overflow-auto"> - <Spinner loading={loading}> + <Spinner isLoading={loading}> {showComponentList && ( <Components baseComponent={baseComponent} diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx index b1065183efb..d3b36317eb3 100644 --- a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx @@ -28,8 +28,16 @@ import { import * as React from 'react'; import Measure from '../../../components/measure/Measure'; import { getLeakValue } from '../../../components/measure/utils'; +import { + CCT_SOFTWARE_QUALITY_METRICS, + OLD_TO_NEW_TAXONOMY_METRICS_MAP, +} from '../../../helpers/constants'; import { translateWithParameters } from '../../../helpers/l10n'; -import { formatMeasure, isDiffMetric } from '../../../helpers/measures'; +import { + areCCTMeasuresComputed as areCCTMeasuresComputedFn, + formatMeasure, + isDiffMetric, +} from '../../../helpers/measures'; import { isApplication, isProject } from '../../../types/component'; import { MetricKey, MetricType } from '../../../types/metrics'; import { Metric, Status, ComponentMeasure as TypeComponentMeasure } from '../../../types/types'; @@ -44,14 +52,27 @@ export default function ComponentMeasure(props: Props) { const isProjectLike = isProject(component.qualifier) || isApplication(component.qualifier); const isReleasability = metric.key === MetricKey.releasability_rating; - const finalMetricKey = isProjectLike && isReleasability ? MetricKey.alert_status : metric.key; + let finalMetricKey = isProjectLike && isReleasability ? MetricKey.alert_status : metric.key; const finalMetricType = isProjectLike && isReleasability ? MetricType.Level : metric.type; + const areCCTMeasasuresComputed = areCCTMeasuresComputedFn(component.measures); + finalMetricKey = areCCTMeasasuresComputed + ? OLD_TO_NEW_TAXONOMY_METRICS_MAP[finalMetricKey as MetricKey] ?? finalMetricKey + : finalMetricKey; + const measure = Array.isArray(component.measures) ? component.measures.find((measure) => measure.metric === finalMetricKey) : undefined; - const value = isDiffMetric(metric.key) ? getLeakValue(measure) : measure?.value; + let value; + if ( + measure?.value !== undefined && + CCT_SOFTWARE_QUALITY_METRICS.includes(measure.metric as MetricKey) + ) { + value = JSON.parse(measure.value).total; + } else { + value = isDiffMetric(metric.key) ? getLeakValue(measure) : measure?.value; + } switch (finalMetricType) { case MetricType.Level: { diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx index be554df01ec..312ea27eb4c 100644 --- a/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx @@ -19,6 +19,10 @@ */ import { ContentCell, NumericalCell, RatingCell } from 'design-system'; import * as React from 'react'; +import { + CCT_SOFTWARE_QUALITY_METRICS, + OLD_TO_NEW_TAXONOMY_METRICS_MAP, +} from '../../../helpers/constants'; import { translate } from '../../../helpers/l10n'; import { isPortfolioLike } from '../../../types/component'; import { MetricKey } from '../../../types/metrics'; @@ -33,6 +37,7 @@ interface ComponentsHeaderProps { } const SHORT_NAME_METRICS = [ + ...CCT_SOFTWARE_QUALITY_METRICS, MetricKey.duplicated_lines_density, MetricKey.new_lines, MetricKey.new_coverage, @@ -60,13 +65,15 @@ export default function ComponentsHeader(props: ComponentsHeaderProps) { Cell = RatingCell; } else { - columns = metrics.map((metric) => - translate( + columns = metrics.map((m: MetricKey) => { + const metric = OLD_TO_NEW_TAXONOMY_METRICS_MAP[m] ?? m; + + return translate( 'metric', metric, SHORT_NAME_METRICS.includes(metric as MetricKey) ? 'short_name' : 'name', - ), - ); + ); + }); Cell = NumericalCell; } diff --git a/server/sonar-web/src/main/js/apps/code/utils.ts b/server/sonar-web/src/main/js/apps/code/utils.ts index 93cd049d8c2..ca7fbc55154 100644 --- a/server/sonar-web/src/main/js/apps/code/utils.ts +++ b/server/sonar-web/src/main/js/apps/code/utils.ts @@ -19,6 +19,7 @@ */ import { getBreadcrumbs, getChildren, getComponent, getComponentData } from '../../api/components'; import { getBranchLikeQuery, isPullRequest } from '../../helpers/branch-like'; +import { CCT_SOFTWARE_QUALITY_METRICS, OLD_TAXONOMY_METRICS } from '../../helpers/constants'; import { BranchLike } from '../../types/branch-like'; import { ComponentQualifier, isPortfolioLike } from '../../types/component'; import { MetricKey } from '../../types/metrics'; @@ -34,9 +35,8 @@ import { const METRICS = [ MetricKey.ncloc, - MetricKey.bugs, - MetricKey.vulnerabilities, - MetricKey.code_smells, + ...CCT_SOFTWARE_QUALITY_METRICS, + ...OLD_TAXONOMY_METRICS, MetricKey.security_hotspots, MetricKey.coverage, MetricKey.duplicated_lines_density, @@ -64,9 +64,8 @@ const NEW_PORTFOLIO_METRICS = [ const LEAK_METRICS = [ MetricKey.new_lines, - MetricKey.bugs, - MetricKey.vulnerabilities, - MetricKey.code_smells, + ...CCT_SOFTWARE_QUALITY_METRICS, + ...OLD_TAXONOMY_METRICS, MetricKey.security_hotspots, MetricKey.new_coverage, MetricKey.new_duplicated_lines_density, diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx index 5faad7c2615..1ded1eb2e24 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx @@ -28,12 +28,11 @@ import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; import { useLocation, useRouter } from '../../../components/hoc/withRouter'; import AnalysisMissingInfoMessage from '../../../components/shared/AnalysisMissingInfoMessage'; import { parseDate } from '../../../helpers/dates'; -import { isDiffMetric } from '../../../helpers/measures'; +import { areCCTMeasuresComputed, isDiffMetric } from '../../../helpers/measures'; import { CodeScope } from '../../../helpers/urls'; import { ApplicationPeriod } from '../../../types/application'; import { Branch } from '../../../types/branch-like'; import { ComponentQualifier } from '../../../types/component'; -import { MetricKey } from '../../../types/metrics'; import { Analysis, GraphType, MeasureHistory } from '../../../types/project-activity'; import { QualityGateStatus } from '../../../types/quality-gates'; import { Component, MeasureEnhanced, Metric, Period, QualityGate } from '../../../types/types'; @@ -99,11 +98,7 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp const hasNewCodeMeasures = measures.some((m) => isDiffMetric(m.metric.key)); // Check if any potentially missing uncomputed measure is not present - const isMissingMeasures = [ - MetricKey.security_issues, - MetricKey.maintainability_issues, - MetricKey.reliability_issues, - ].some((key) => !measures.find((measure) => measure.metric.key === key)); + const isMissingMeasures = !areCCTMeasuresComputed(measures); const selectTab = (tab: CodeScope) => { router.replace({ query: { ...query, codeScope: tab } }); diff --git a/server/sonar-web/src/main/js/apps/overview/branches/SoftwareImpactMeasureCard.tsx b/server/sonar-web/src/main/js/apps/overview/branches/SoftwareImpactMeasureCard.tsx index 52fcb66b016..9115a05f775 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/SoftwareImpactMeasureCard.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/SoftwareImpactMeasureCard.tsx @@ -17,15 +17,20 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { LinkHighlight, LinkStandalone } from '@sonarsource/echoes-react'; import styled from '@emotion/styled'; +import { LinkHighlight, LinkStandalone } from '@sonarsource/echoes-react'; import classNames from 'classnames'; import { BasicSeparator, LightGreyCard, TextBold, TextSubdued } from 'design-system'; import * as React from 'react'; import { useIntl } from 'react-intl'; import Tooltip from '../../../components/controls/Tooltip'; import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils'; +import { + SOFTWARE_QUALITIES_METRIC_KEYS_MAP, + getIssueTypeBySoftwareQuality, +} from '../../../helpers/issues'; import { formatMeasure } from '../../../helpers/measures'; +import { isDefined } from '../../../helpers/types'; import { getComponentIssuesUrl } from '../../../helpers/urls'; import { Branch } from '../../../types/branch-like'; import { @@ -39,11 +44,6 @@ import { OverviewDisabledLinkTooltip } from '../components/OverviewDisabledLinkT import { softwareQualityToMeasure } from '../utils'; import SoftwareImpactMeasureBreakdownCard from './SoftwareImpactMeasureBreakdownCard'; import SoftwareImpactMeasureRating from './SoftwareImpactMeasureRating'; -import { isDefined } from '../../../helpers/types'; -import { - getIssueTypeBySoftwareQuality, - SOFTWARE_QUALITIES_METRIC_KEYS_MAP, -} from '../../../helpers/issues'; export interface SoftwareImpactBreakdownCardProps { component: Component; @@ -69,7 +69,7 @@ export function SoftwareImpactMeasureCard(props: Readonly<SoftwareImpactBreakdow // Find rating measure const ratingMeasure = measures.find((m) => m.metric.key === ratingMetricKey); - const count = measure?.total ?? alternativeMeasure?.value; + const count = formatMeasure(measure?.total ?? alternativeMeasure?.value, MetricType.ShortInteger); const totalLinkHref = getComponentIssuesUrl(component.key, { ...DEFAULT_ISSUES_QUERY, @@ -125,7 +125,7 @@ export function SoftwareImpactMeasureCard(props: Readonly<SoftwareImpactBreakdow highlight={LinkHighlight.CurrentColor} to={totalLinkHref} > - {formatMeasure(count, MetricType.ShortInteger)} + {count} </LinkStandalone> </Tooltip> ) : ( diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx index 07fe0ed08d4..2967f417707 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx @@ -37,11 +37,18 @@ import { themeColor, } from 'design-system'; import * as React from 'react'; +import { useIntl } from 'react-intl'; import { getBranchLikeQuery } from '../../helpers/branch-like'; -import { ISSUE_TYPES } from '../../helpers/constants'; -import { ISSUETYPE_METRIC_KEYS_MAP } from '../../helpers/issues'; -import { translate } from '../../helpers/l10n'; -import { formatMeasure } from '../../helpers/measures'; +import { SOFTWARE_QUALITIES } from '../../helpers/constants'; +import { + ISSUETYPE_METRIC_KEYS_MAP, + SOFTWARE_QUALITIES_METRIC_KEYS_MAP, + getIssueTypeBySoftwareQuality, +} from '../../helpers/issues'; +import { + areCCTMeasuresComputed as areCCTMeasuresComputedFn, + formatMeasure, +} from '../../helpers/measures'; import { collapsedDirFromPath, fileFromPath } from '../../helpers/path'; import { omitNil } from '../../helpers/request'; import { getBaseUrl } from '../../helpers/system'; @@ -69,14 +76,19 @@ interface Props { sourceViewerFile: SourceViewerFile; } -export default class SourceViewerHeader extends React.PureComponent<Props> { - openInWorkspace = () => { - const { key } = this.props.sourceViewerFile; - this.props.openComponent({ branchLike: this.props.branchLike, key }); - }; +export default function SourceViewerHeader(props: Readonly<Props>) { + const intl = useIntl(); + + const { showMeasures, branchLike, hidePinOption, openComponent, componentMeasures } = props; + const { key, measures, path, project, projectName, q } = props.sourceViewerFile; + const unitTestsOrLines = q === ComponentQualifier.TestFile ? MetricKey.tests : MetricKey.lines; + + const query = new URLSearchParams(omitNil({ key, ...getBranchLikeQuery(branchLike) })).toString(); - renderIssueMeasures = () => { - const { branchLike, componentMeasures, sourceViewerFile } = this.props; + const rawSourcesLink = `${getBaseUrl()}/api/sources/raw?${query}`; + + const renderIssueMeasures = () => { + const areCCTMeasuresComputed = areCCTMeasuresComputedFn(componentMeasures); return ( componentMeasures && @@ -85,163 +97,192 @@ export default class SourceViewerHeader extends React.PureComponent<Props> { <StyledVerticalSeparator className="sw-h-8 sw-mx-6" /> <div className="sw-flex sw-gap-6"> - {ISSUE_TYPES.map((type: IssueType) => { - const params = { - ...getBranchLikeQuery(branchLike), - files: sourceViewerFile.path, - ...DEFAULT_ISSUES_QUERY, - types: type, - }; - + {SOFTWARE_QUALITIES.map((quality) => { + const { deprecatedMetric, metric } = SOFTWARE_QUALITIES_METRIC_KEYS_MAP[quality]; const measure = componentMeasures.find( - (m) => m.metric === ISSUETYPE_METRIC_KEYS_MAP[type].metric, + (m) => m.metric === (areCCTMeasuresComputed ? metric : deprecatedMetric), ); + const measureValue = areCCTMeasuresComputed + ? JSON.parse(measure?.value ?? 'null').total + : measure?.value ?? 0; - const linkUrl = - type === IssueType.SecurityHotspot - ? getComponentSecurityHotspotsUrl(sourceViewerFile.project, params) - : getComponentIssuesUrl(sourceViewerFile.project, params); + const linkUrl = getComponentIssuesUrl(project, { + ...getBranchLikeQuery(branchLike), + files: path, + ...DEFAULT_ISSUES_QUERY, + ...(areCCTMeasuresComputed + ? { impactSoftwareQualities: quality } + : { types: getIssueTypeBySoftwareQuality(quality) }), + }); + + const qualityTitle = intl.formatMessage({ id: `metric.${metric}.short_name` }); return ( - <div className="sw-flex sw-flex-col sw-gap-1" key={type}> + <div className="sw-flex sw-flex-col sw-gap-1" key={quality}> <Note className="it__source-viewer-header-measure-label sw-body-lg"> - {translate('issue.type', type)} + {qualityTitle} </Note> <span> - <StyledDrilldownLink className="sw-body-md" to={linkUrl}> - {formatMeasure(measure?.value ?? 0, MetricType.Integer)} + <StyledDrilldownLink + className="sw-body-md" + aria-label={intl.formatMessage( + { id: 'source_viewer.issue_link_x' }, + { + count: formatMeasure(measureValue, MetricType.Integer), + quality: qualityTitle, + }, + )} + to={linkUrl} + > + {formatMeasure(measureValue, MetricType.Integer)} </StyledDrilldownLink> </span> </div> ); })} + + <div className="sw-flex sw-flex-col sw-gap-1" key={IssueType.SecurityHotspot}> + <Note className="it__source-viewer-header-measure-label sw-body-lg"> + {intl.formatMessage({ id: `issue.type.${IssueType.SecurityHotspot}` })} + </Note> + + <span> + <StyledDrilldownLink + className="sw-body-md" + to={getComponentSecurityHotspotsUrl(project, { + ...getBranchLikeQuery(branchLike), + files: path, + ...DEFAULT_ISSUES_QUERY, + types: IssueType.SecurityHotspot, + })} + > + {formatMeasure( + componentMeasures.find( + (m) => + m.metric === ISSUETYPE_METRIC_KEYS_MAP[IssueType.SecurityHotspot].metric, + )?.value ?? 0, + MetricType.Integer, + )} + </StyledDrilldownLink> + </span> + </div> </div> </> ) ); }; - render() { - const { showMeasures } = this.props; - const { key, measures, path, project, projectName, q } = this.props.sourceViewerFile; - const unitTestsOrLines = q === ComponentQualifier.TestFile ? MetricKey.tests : MetricKey.lines; - - const query = new URLSearchParams( - omitNil({ key, ...getBranchLikeQuery(this.props.branchLike) }), - ).toString(); - - const rawSourcesLink = `${getBaseUrl()}/api/sources/raw?${query}`; - - return ( - <StyledHeaderContainer - className={ - 'it__source-viewer-header sw-body-sm sw-flex sw-items-center sw-px-4 sw-py-3 ' + - 'sw-relative' - } - > - <div className="sw-flex sw-flex-1 sw-flex-col sw-gap-1 sw-mr-5 sw-my-1"> - <div className="sw-flex sw-gap-1 sw-items-center"> - <LinkStandalone - iconLeft={<ProjectIcon className="sw-mr-2" />} - to={getBranchLikeUrl(project, this.props.branchLike)} - > - {projectName} - </LinkStandalone> - </div> - - <div className="sw-flex sw-gap-2 sw-items-center"> - <QualifierIcon qualifier={q} /> - - {collapsedDirFromPath(path)} - - {fileFromPath(path)} - - <span> - <ClipboardIconButton - aria-label={translate('component_viewer.copy_path_to_clipboard')} - copyValue={path} - /> - </span> - </div> + return ( + <StyledHeaderContainer + className={ + 'it__source-viewer-header sw-body-sm sw-flex sw-items-center sw-px-4 sw-py-3 ' + + 'sw-relative' + } + > + <div className="sw-flex sw-flex-1 sw-flex-col sw-gap-1 sw-mr-5 sw-my-1"> + <div className="sw-flex sw-gap-1 sw-items-center"> + <LinkStandalone + iconLeft={<ProjectIcon className="sw-mr-2" />} + to={getBranchLikeUrl(project, branchLike)} + > + {projectName} + </LinkStandalone> </div> - {showMeasures && ( - <div className="sw-flex sw-gap-6 sw-items-center"> - {isDefined(measures[unitTestsOrLines]) && ( - <div className="sw-flex sw-flex-col sw-gap-1"> - <Note className="it__source-viewer-header-measure-label sw-body-lg"> - {translate(`metric.${unitTestsOrLines}.name`)} - </Note> - - <span className="sw-body-lg"> - {formatMeasure(measures[unitTestsOrLines], MetricType.ShortInteger)} - </span> - </div> - )} - - {isDefined(measures.coverage) && ( - <div className="sw-flex sw-flex-col sw-gap-1"> - <Note className="it__source-viewer-header-measure-label sw-body-lg"> - {translate('metric.coverage.name')} - </Note> + <div className="sw-flex sw-gap-2 sw-items-center"> + <QualifierIcon qualifier={q} /> - <span className="sw-body-lg"> - {formatMeasure(measures.coverage, MetricType.Percent)} - </span> - </div> - )} + {collapsedDirFromPath(path)} - {isDefined(measures.duplicationDensity) && ( - <div className="sw-flex sw-flex-col sw-gap-1"> - <Note className="it__source-viewer-header-measure-label sw-body-lg"> - {translate('duplications')} - </Note> + {fileFromPath(path)} - <span className="sw-body-lg"> - {formatMeasure(measures.duplicationDensity, MetricType.Percent)} - </span> - </div> + <span> + <ClipboardIconButton + aria-label={intl.formatMessage({ id: 'component_viewer.copy_path_to_clipboard' })} + copyValue={path} + /> + </span> + </div> + </div> + + {showMeasures && ( + <div className="sw-flex sw-gap-6 sw-items-center"> + {isDefined(measures[unitTestsOrLines]) && ( + <div className="sw-flex sw-flex-col sw-gap-1"> + <Note className="it__source-viewer-header-measure-label sw-body-lg"> + {intl.formatMessage({ id: `metric.${unitTestsOrLines}.name` })} + </Note> + + <span className="sw-body-lg"> + {formatMeasure(measures[unitTestsOrLines], MetricType.ShortInteger)} + </span> + </div> + )} + + {isDefined(measures.coverage) && ( + <div className="sw-flex sw-flex-col sw-gap-1"> + <Note className="it__source-viewer-header-measure-label sw-body-lg"> + {intl.formatMessage({ id: 'metric.coverage.name' })} + </Note> + + <span className="sw-body-lg"> + {formatMeasure(measures.coverage, MetricType.Percent)} + </span> + </div> + )} + + {isDefined(measures.duplicationDensity) && ( + <div className="sw-flex sw-flex-col sw-gap-1"> + <Note className="it__source-viewer-header-measure-label sw-body-lg"> + {intl.formatMessage({ id: 'duplications' })} + </Note> + + <span className="sw-body-lg"> + {formatMeasure(measures.duplicationDensity, MetricType.Percent)} + </span> + </div> + )} + + {renderIssueMeasures()} + </div> + )} + + <Dropdown + id="source-viewer-header-actions" + overlay={ + <> + <ItemLink isExternal to={getCodeUrl(project, branchLike, key)}> + {intl.formatMessage({ id: 'component_viewer.new_window' })} + </ItemLink> + + {!hidePinOption && ( + <ItemButton + className="it__js-workspace" + onClick={() => { + openComponent({ branchLike, key }); + }} + > + {intl.formatMessage({ id: 'component_viewer.open_in_workspace' })} + </ItemButton> )} - {this.renderIssueMeasures()} - </div> - )} - - <Dropdown - id="source-viewer-header-actions" - overlay={ - <> - <ItemLink - isExternal - to={getCodeUrl(this.props.sourceViewerFile.project, this.props.branchLike, key)} - > - {translate('component_viewer.new_window')} - </ItemLink> - - {!this.props.hidePinOption && ( - <ItemButton className="it__js-workspace" onClick={this.openInWorkspace}> - {translate('component_viewer.open_in_workspace')} - </ItemButton> - )} - - <ItemLink isExternal to={rawSourcesLink}> - {translate('component_viewer.show_raw_source')} - </ItemLink> - </> - } - placement={PopupPlacement.BottomRight} - zLevel={PopupZLevel.Global} - > - <InteractiveIcon - aria-label={translate('component_viewer.action_menu')} - className="it__js-actions sw-flex-0 sw-ml-4 sw-px-3 sw-py-2" - Icon={MenuIcon} - /> - </Dropdown> - </StyledHeaderContainer> - ); - } + <ItemLink isExternal to={rawSourcesLink}> + {intl.formatMessage({ id: 'component_viewer.show_raw_source' })} + </ItemLink> + </> + } + placement={PopupPlacement.BottomRight} + zLevel={PopupZLevel.Global} + > + <InteractiveIcon + aria-label={intl.formatMessage({ id: 'component_viewer.action_menu' })} + className="it__js-actions sw-flex-0 sw-ml-4 sw-px-3 sw-py-2" + Icon={MenuIcon} + /> + </Dropdown> + </StyledHeaderContainer> + ); } const StyledDrilldownLink = styled(DrilldownLink)` diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx index f29f2d82078..dd75fad448a 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx @@ -23,9 +23,13 @@ import * as React from 'react'; import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import UsersServiceMock from '../../../api/mocks/UsersServiceMock'; +import { CCT_SOFTWARE_QUALITY_METRICS } from '../../../helpers/constants'; +import { isDiffMetric } from '../../../helpers/measures'; import { HttpStatus } from '../../../helpers/request'; -import { mockIssue, mockLoggedInUser } from '../../../helpers/testMocks'; +import { mockIssue, mockLoggedInUser, mockMeasure } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import { byLabelText } from '../../../helpers/testSelector'; +import { MetricKey } from '../../../types/metrics'; import { RestUserDetailed } from '../../../types/users'; import SourceViewer, { Props } from '../SourceViewer'; import loadIssues from '../helpers/loadIssues'; @@ -256,7 +260,7 @@ it('should show issue indicator', async () => { await user.click( issueRow.getByRole('button', { - name: 'source_viewer.issues_on_line.X_issues_of_type_Y.source_viewer.issues_on_line.show.2.issue.type.BUG.plural', + name: 'source_viewer.issues_on_line.multiple_issues_same_category.true.2.issue.clean_code_attribute_category.responsible', }), ); }); @@ -338,6 +342,43 @@ it('should highlight symbol', async () => { }); }); +it('should show software quality measures in header', async () => { + renderSourceViewer({ componentMeasures: generateMeasures(), showMeasures: true }); + + expect( + await byLabelText('source_viewer.issue_link_x.3.metric.security_issues.short_name').find(), + ).toBeInTheDocument(); + expect( + await byLabelText('source_viewer.issue_link_x.3.metric.reliability_issues.short_name').find(), + ).toBeInTheDocument(); + expect( + await byLabelText( + 'source_viewer.issue_link_x.3.metric.maintainability_issues.short_name', + ).find(), + ).toBeInTheDocument(); +}); + +it('should show old issue measures in header', async () => { + renderSourceViewer({ + componentMeasures: generateMeasures().filter( + (m) => !CCT_SOFTWARE_QUALITY_METRICS.includes(m.metric as MetricKey), + ), + showMeasures: true, + }); + + expect( + await byLabelText('source_viewer.issue_link_x.1.metric.security_issues.short_name').find(), + ).toBeInTheDocument(); + expect( + await byLabelText('source_viewer.issue_link_x.1.metric.reliability_issues.short_name').find(), + ).toBeInTheDocument(); + expect( + await byLabelText( + 'source_viewer.issue_link_x.1.metric.maintainability_issues.short_name', + ).find(), + ).toBeInTheDocument(); +}); + it('should show correct message when component is not asscessible', async () => { componentsHandler.setFailLoadingComponentStatus(HttpStatus.Forbidden); renderSourceViewer(); @@ -353,6 +394,32 @@ it('should show correct message when component does not exist', async () => { expect(await screen.findByText('component_viewer.no_component')).toBeInTheDocument(); }); +function generateMeasures(qualitiesValue = '3.0', overallValue = '1.0', newValue = '2.0') { + return [ + ...[ + MetricKey.security_issues, + MetricKey.reliability_issues, + MetricKey.maintainability_issues, + ].map((metric) => + mockMeasure({ metric, value: JSON.stringify({ total: qualitiesValue }), period: undefined }), + ), + ...[ + MetricKey.ncloc, + MetricKey.new_lines, + MetricKey.bugs, + MetricKey.vulnerabilities, + MetricKey.code_smells, + MetricKey.security_hotspots, + MetricKey.coverage, + MetricKey.new_coverage, + ].map((metric) => + isDiffMetric(metric) + ? mockMeasure({ metric, period: { index: 1, value: newValue } }) + : mockMeasure({ metric, value: overallValue, period: undefined }), + ), + ]; +} + function renderSourceViewer(override?: Partial<Props>) { const { rerender } = renderComponent( <SourceViewer diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx index 84fea1e6b14..c4d4d50c824 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx @@ -20,9 +20,8 @@ import { IssueIndicatorButton, LineIssuesIndicatorIcon, LineMeta } from 'design-system'; import { uniq } from 'lodash'; import * as React from 'react'; +import { useIntl } from 'react-intl'; import Tooltip from '../../../components/controls/Tooltip'; -import { sortByType } from '../../../helpers/issues'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; import { Issue, SourceLine } from '../../../types/types'; const MOUSE_LEAVE_DELAY = 0.25; @@ -37,33 +36,32 @@ export interface LineIssuesIndicatorProps { export function LineIssuesIndicator(props: LineIssuesIndicatorProps) { const { issues, issuesOpen, line } = props; const hasIssues = issues.length > 0; + const intl = useIntl(); if (!hasIssues) { return <LineMeta />; } - const mostImportantIssue = sortByType(issues)[0]; - const issueTypes = uniq(issues.map((i) => i.type)); - - const tooltipShowHide = translate('source_viewer.issues_on_line', issuesOpen ? 'hide' : 'show'); + const issueAttributeCategories = uniq(issues.map((issue) => issue.cleanCodeAttributeCategory)); let tooltipContent; - if (issueTypes.length > 1) { - tooltipContent = translateWithParameters( - 'source_viewer.issues_on_line.multiple_issues', - tooltipShowHide, - ); - } else if (issues.length === 1) { - tooltipContent = translateWithParameters( - 'source_viewer.issues_on_line.issue_of_type_X', - tooltipShowHide, - translate('issue.type', mostImportantIssue.type), + + if (issueAttributeCategories.length > 1) { + tooltipContent = intl.formatMessage( + { id: 'source_viewer.issues_on_line.multiple_issues' }, + { show: !issuesOpen }, ); } else { - tooltipContent = translateWithParameters( - 'source_viewer.issues_on_line.X_issues_of_type_Y', - tooltipShowHide, - issues.length, - translate('issue.type', mostImportantIssue.type, 'plural'), + tooltipContent = intl.formatMessage( + { id: 'source_viewer.issues_on_line.multiple_issues_same_category' }, + { + show: !issuesOpen, + count: issues.length, + category: intl + .formatMessage({ + id: `issue.clean_code_attribute_category.${issueAttributeCategories[0]}`, + }) + .toLowerCase(), + }, ); } @@ -75,10 +73,7 @@ export function LineIssuesIndicator(props: LineIssuesIndicatorProps) { aria-expanded={issuesOpen} onClick={props.onClick} > - <LineIssuesIndicatorIcon - issuesCount={issues.length} - mostImportantIssueType={mostImportantIssue.type} - /> + <LineIssuesIndicatorIcon issuesCount={issues.length} /> </IssueIndicatorButton> </Tooltip> </LineMeta> diff --git a/server/sonar-web/src/main/js/helpers/__tests__/measures-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/measures-test.ts index f22797dcaf8..820f4a6636b 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/measures-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/measures-test.ts @@ -19,10 +19,16 @@ */ import { MetricKey, MetricType } from '../../types/metrics'; import { Dict } from '../../types/types'; +import { CCT_SOFTWARE_QUALITY_METRICS } from '../constants'; import { getMessages } from '../l10nBundle'; -import { enhanceConditionWithMeasure, formatMeasure, isPeriodBestValue } from '../measures'; +import { + areCCTMeasuresComputed, + enhanceConditionWithMeasure, + formatMeasure, + isPeriodBestValue, +} from '../measures'; import { mockQualityGateStatusCondition } from '../mocks/quality-gates'; -import { mockMeasureEnhanced, mockMetric } from '../testMocks'; +import { mockMeasure, mockMeasureEnhanced, mockMetric } from '../testMocks'; jest.unmock('../l10n'); @@ -259,3 +265,21 @@ describe('#formatMeasure()', () => { expect(formatMeasure(undefined, MetricType.Integer)).toBe(''); }); }); + +describe('areCCTMeasuresComputed', () => { + it('returns true when measures include maintainability_,security_,reliability_issues', () => { + expect( + areCCTMeasuresComputed(CCT_SOFTWARE_QUALITY_METRICS.map((metric) => mockMeasure({ metric }))), + ).toBe(true); + }); + + it('returns false otherwise', () => { + expect(areCCTMeasuresComputed([mockMeasure()])).toBe(false); + expect( + areCCTMeasuresComputed([ + mockMeasure(), + mockMeasure({ metric: CCT_SOFTWARE_QUALITY_METRICS[0] }), + ]), + ).toBe(false); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/constants.ts b/server/sonar-web/src/main/js/helpers/constants.ts index a6ee2cb6fd8..63de008d849 100644 --- a/server/sonar-web/src/main/js/helpers/constants.ts +++ b/server/sonar-web/src/main/js/helpers/constants.ts @@ -86,6 +86,24 @@ export const ISSUE_TYPES: IssueType[] = [ IssueType.SecurityHotspot, ]; +export const CCT_SOFTWARE_QUALITY_METRICS = [ + MetricKey.security_issues, + MetricKey.reliability_issues, + MetricKey.maintainability_issues, +]; + +export const OLD_TAXONOMY_METRICS = [ + MetricKey.vulnerabilities, + MetricKey.bugs, + MetricKey.code_smells, +]; + +export const OLD_TO_NEW_TAXONOMY_METRICS_MAP: { [key in MetricKey]?: MetricKey } = { + [MetricKey.vulnerabilities]: MetricKey.security_issues, + [MetricKey.bugs]: MetricKey.reliability_issues, + [MetricKey.code_smells]: MetricKey.maintainability_issues, +}; + export const RESOLUTIONS = [ IssueResolution.Unresolved, IssueResolution.FalsePositive, diff --git a/server/sonar-web/src/main/js/helpers/issues.ts b/server/sonar-web/src/main/js/helpers/issues.ts index c62a9b29222..204a54d606b 100644 --- a/server/sonar-web/src/main/js/helpers/issues.ts +++ b/server/sonar-web/src/main/js/helpers/issues.ts @@ -19,12 +19,12 @@ */ import { BugIcon, CodeSmellIcon, SecurityHotspotIcon, VulnerabilityIcon } from 'design-system'; import { flatten, sortBy } from 'lodash'; +import { SoftwareQuality } from '../types/clean-code-taxonomy'; import { IssueType, RawIssue } from '../types/issues'; import { MetricKey } from '../types/metrics'; import { Dict, Flow, FlowLocation, FlowType, Issue, TextRange } from '../types/types'; import { UserBase } from '../types/users'; import { ISSUE_TYPES } from './constants'; -import { SoftwareQuality } from '../types/clean-code-taxonomy'; interface Rule {} diff --git a/server/sonar-web/src/main/js/helpers/measures.ts b/server/sonar-web/src/main/js/helpers/measures.ts index 21361041699..f8cef7ad0ae 100644 --- a/server/sonar-web/src/main/js/helpers/measures.ts +++ b/server/sonar-web/src/main/js/helpers/measures.ts @@ -23,7 +23,7 @@ import { QualityGateStatusConditionEnhanced, } from '../types/quality-gates'; import { Dict, Measure, MeasureEnhanced, Metric } from '../types/types'; -import { ONE_SECOND } from './constants'; +import { CCT_SOFTWARE_QUALITY_METRICS, ONE_SECOND } from './constants'; import { translate, translateWithParameters } from './l10n'; import { getCurrentLocale } from './l10nBundle'; import { isDefined } from './types'; @@ -79,6 +79,18 @@ export function findMeasure(measures: MeasureEnhanced[], metric: MetricKey | str return measures.find((measure) => measure.metric.key === metric); } +export function areCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[]) { + return CCT_SOFTWARE_QUALITY_METRICS.every((metric) => + measures?.find((measure) => + isMeasureEnhanced(measure) ? measure.metric.key === metric : measure.metric === metric, + ), + ); +} + +function isMeasureEnhanced(measure: Measure | MeasureEnhanced): measure is MeasureEnhanced { + return (measure.metric as Metric)?.key !== undefined; +} + const HOURS_IN_DAY = 8; type Formatter = (value: string | number, options?: Dict<unknown>) => string; diff --git a/server/sonar-web/src/main/js/helpers/mocks/metrics.ts b/server/sonar-web/src/main/js/helpers/mocks/metrics.ts index 1134b4e62b7..75a97dcabbd 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/metrics.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/metrics.ts @@ -910,6 +910,15 @@ export const DEFAULT_METRICS: Dict<Metric> = { qualitative: true, hidden: true, }, + reliability_issues: { + key: 'reliability_issues', + type: 'INT', + name: 'Reliability', + description: 'Reliability issues', + direction: -1, + qualitative: true, + hidden: false, + }, reliability_rating: { key: 'reliability_rating', type: 'RATING', @@ -1012,6 +1021,15 @@ export const DEFAULT_METRICS: Dict<Metric> = { hidden: false, decimalScale: 1, }, + security_issues: { + key: 'security_issues', + type: 'INT', + name: 'Security', + description: 'Security issues', + direction: -1, + qualitative: true, + hidden: false, + }, security_rating: { key: 'security_rating', type: 'RATING', @@ -1172,6 +1190,15 @@ export const DEFAULT_METRICS: Dict<Metric> = { qualitative: false, hidden: false, }, + maintainability_issues: { + key: 'maintainability_issues', + type: 'INT', + name: 'Maintainability', + description: 'Maintainability issues', + direction: -1, + qualitative: true, + hidden: false, + }, sqale_index: { key: 'sqale_index', type: 'WORK_DUR', |