From: Ambroise C Date: Fri, 13 Oct 2023 14:58:17 +0000 (+0200) Subject: SONAR-20679 Show measures variations for each analysis in project overview X-Git-Tag: 10.3.0.82913~176 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=4ea7918a45f74e4f393d79cedf799289026d3174;p=sonarqube.git SONAR-20679 Show measures variations for each analysis in project overview --- diff --git a/server/sonar-web/design-system/src/components/icons/TrendDownIcon.tsx b/server/sonar-web/design-system/src/components/icons/TrendDownIcon.tsx new file mode 100644 index 00000000000..764a0729987 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/TrendDownIcon.tsx @@ -0,0 +1,24 @@ +/* + * 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 { ArrowDownRightIcon as OcticonArrowDownRightIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export const TrendDownIcon = OcticonHoc(OcticonArrowDownRightIcon, 'TrendDownIcon'); diff --git a/server/sonar-web/design-system/src/components/icons/TrendUpIcon.tsx b/server/sonar-web/design-system/src/components/icons/TrendUpIcon.tsx new file mode 100644 index 00000000000..cbf92fec67f --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/TrendUpIcon.tsx @@ -0,0 +1,24 @@ +/* + * 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 { ArrowUpRightIcon as OcticonArrowUpRightIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export const TrendUpIcon = OcticonHoc(OcticonArrowUpRightIcon, 'TrendUpIcon'); diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts index 8671cd76022..a6264cb3a1f 100644 --- a/server/sonar-web/design-system/src/components/icons/index.ts +++ b/server/sonar-web/design-system/src/components/icons/index.ts @@ -84,6 +84,8 @@ export { StatusReopenedIcon } from './StatusReopenedIcon'; export { StatusResolvedIcon } from './StatusResolvedIcon'; export { TestFileIcon } from './TestFileIcon'; export { TrashIcon } from './TrashIcon'; +export { TrendDownIcon } from './TrendDownIcon'; +export { TrendUpIcon } from './TrendUpIcon'; export { TriangleDownIcon } from './TriangleDownIcon'; export { TriangleLeftIcon } from './TriangleLeftIcon'; export { TriangleRightIcon } from './TriangleRightIcon'; diff --git a/server/sonar-web/src/main/js/api/time-machine.ts b/server/sonar-web/src/main/js/api/time-machine.ts index edd38f5dac8..616493ff70f 100644 --- a/server/sonar-web/src/main/js/api/time-machine.ts +++ b/server/sonar-web/src/main/js/api/time-machine.ts @@ -20,11 +20,12 @@ import { throwGlobalError } from '../helpers/error'; import { getJSON } from '../helpers/request'; import { BranchParameters } from '../types/branch-like'; +import { MetricKey } from '../types/metrics'; import { Paging } from '../types/types'; export interface TimeMachineResponse { measures: { - metric: string; + metric: MetricKey; history: Array<{ date: string; value?: string }>; }[]; paging: Paging; diff --git a/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx index c3a2d77011a..a5f6c732e78 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx @@ -38,6 +38,7 @@ import { MeasureHistory, } from '../../../types/project-activity'; import { Component, Metric } from '../../../types/types'; +import { getAnalysisVariations } from '../utils'; import Analysis from './Analysis'; export interface ActivityPanelProps { @@ -88,7 +89,16 @@ export function ActivityPanel(props: ActivityPanelProps) { startDate.getTime() > leakPeriodDate.getTime() ? startDate : leakPeriodDate; } - const filteredAnalyses = analyses.filter((a) => a.events.length > 0).slice(0, MAX_ANALYSES_NB); + const displayedAnalyses = analyses.slice(0, MAX_ANALYSES_NB); + + const analysisVariations = React.useMemo( + () => + getAnalysisVariations( + measuresHistory, + Math.min(analyses.length, MAX_ANALYSES_NB + 1), + ).reverse(), + [measuresHistory, analyses.length], + ); return (
@@ -116,13 +126,18 @@ export function ActivityPanel(props: ActivityPanelProps) { - {filteredAnalyses.length === 0 ? ( + {displayedAnalyses.length === 0 ? (

{translate('no_results')}

) : ( - filteredAnalyses.map((analysis, index) => ( + displayedAnalyses.map((analysis, index) => (
- - {index !== filteredAnalyses.length - 1 && } + + {index !== displayedAnalyses.length - 1 && }
)) )} diff --git a/server/sonar-web/src/main/js/apps/overview/branches/Analysis.tsx b/server/sonar-web/src/main/js/apps/overview/branches/Analysis.tsx index 7c729d4cb51..88507be61db 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/Analysis.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/Analysis.tsx @@ -23,17 +23,23 @@ import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; import { translate } from '../../../helpers/l10n'; import { ComponentQualifier } from '../../../types/component'; import { + AnalysisMeasuresVariations, ProjectAnalysisEventCategory, Analysis as TypeAnalysis, } from '../../../types/project-activity'; +import { AnalysisVariations } from './AnalysisVariations'; import Event from './Event'; export interface AnalysisProps { analysis: TypeAnalysis; + isFirstAnalysis?: boolean; qualifier: string; + variations?: AnalysisMeasuresVariations; } -export function Analysis({ analysis, ...props }: AnalysisProps) { +export function Analysis(props: Readonly) { + const { analysis, isFirstAnalysis, qualifier, variations } = props; + const sortedEvents = sortBy( analysis.events, (event) => { @@ -53,8 +59,8 @@ export function Analysis({ analysis, ...props }: AnalysisProps) { ); // use `TRK` for all components but applications - const qualifier = - props.qualifier === ComponentQualifier.Application + const displayedQualifier = + qualifier === ComponentQualifier.Application ? ComponentQualifier.Application : ComponentQualifier.Project; @@ -66,7 +72,11 @@ export function Analysis({ analysis, ...props }: AnalysisProps) { {sortedEvents.length > 0 ? sortedEvents.map((event) => ) - : translate('project_activity.analyzed', qualifier)} + : translate('project_activity.analyzed', displayedQualifier)} + + {qualifier === ComponentQualifier.Project && variations !== undefined && ( + + )}
); } diff --git a/server/sonar-web/src/main/js/apps/overview/branches/AnalysisVariations.tsx b/server/sonar-web/src/main/js/apps/overview/branches/AnalysisVariations.tsx new file mode 100644 index 00000000000..97cfd98ce07 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/branches/AnalysisVariations.tsx @@ -0,0 +1,146 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import styled from '@emotion/styled'; +import { TrendDownIcon, TrendUpIcon, themeColor } from 'design-system'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { formatMeasure } from '../../../helpers/measures'; +import { MetricType } from '../../../types/metrics'; +import { AnalysisMeasuresVariations } from '../../../types/project-activity'; + +interface AnalysisVariationsProps { + isFirstAnalysis?: boolean; + variations: AnalysisMeasuresVariations; +} + +interface VariationProps { + isGoodIfGrowing: boolean; + label: string; + showVariationIcon?: boolean; + valueType?: MetricType; + variation?: number; +} + +function Variation(props: Readonly) { + const { + isGoodIfGrowing, + label, + showVariationIcon = true, + valueType = MetricType.Integer, + variation, + } = props; + + if (variation === undefined) { + return null; + } + + const formattedValue = formatMeasure(variation, valueType); + + if (!showVariationIcon) { + return ( + + {formattedValue} {} + + ); + } + + let variationIcon = =; + + if (variation !== 0) { + const ArrowIcon = variation > 0 ? TrendUpIcon : TrendDownIcon; + const ArrowIconContainer = + variation > 0 === isGoodIfGrowing + ? CaYCCompliantIconContainer + : CaYCNonCompliantIconContainer; + + variationIcon = ( + + + + ); + } + + const variationToDisplay = formattedValue.startsWith('-') ? formattedValue : `+${formattedValue}`; + + return ( + + {variationIcon} {variationToDisplay} {} + + ); +} + +export function AnalysisVariations(props: Readonly) { + const { isFirstAnalysis, variations } = props; + + const issuesVariation = + (variations.bugs ?? 0) + (variations.code_smells ?? 0) + (variations.vulnerabilities ?? 0); + const coverageVariation = variations.coverage; + const duplicationsVariation = variations.duplicated_lines_density; + + return ( +
+ + + {coverageVariation !== undefined && } + + {duplicationsVariation !== undefined && } + +
+ ); +} + +const CaYCCompliantIconContainer = styled.span` + color: ${themeColor('iconSuccess')}; +`; + +const CaYCNonCompliantIconContainer = styled.span` + color: ${themeColor('iconError')}; +`; + +const EqualIconContainer = styled.span` + color: ${themeColor('iconInfo')}; +`; + +const SeparatorContainer = styled.span` + color: ${themeColor('iconStatus')}; +`; diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/ActivityPanel-it.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/ActivityPanel-it.tsx index 96a7717ac29..0c02104405e 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/ActivityPanel-it.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/ActivityPanel-it.tsx @@ -23,11 +23,14 @@ import { mockComponent } from '../../../../helpers/mocks/component'; import { mockAnalysis, mockAnalysisEvent, + mockHistoryItem, mockMeasureHistory, } from '../../../../helpers/mocks/project-activity'; import { mockMetric } from '../../../../helpers/testMocks'; +import { parseDate } from '../../../../helpers/dates'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { MetricKey } from '../../../../types/metrics'; import { ApplicationAnalysisEventCategory, DefinitionChangeType, @@ -42,10 +45,74 @@ it('should render correctly', async () => { expect(screen.getByText(/event.category.OTHER/)).toBeInTheDocument(); expect(screen.getByText(/event.category.DEFINITION_CHANGE/)).toBeInTheDocument(); expect(screen.getByText('event.sqUpgrade10.2')).toBeInTheDocument(); + + // Checking measures variations + expect(screen.getAllByText(/project_activity\.graphs\.coverage$/)).toHaveLength(3); + expect(screen.getAllByText(/project_activity\.graphs\.duplications$/)).toHaveLength(3); + // Analysis 1 (latest) + expect(screen.getByText(/^\+0 project_activity\.graphs\.issues$/)).toBeInTheDocument(); + expect(screen.getByText(/^\+6\.5% project_activity\.graphs\.duplications$/)).toBeInTheDocument(); + // Analysis 2 + expect(screen.getByText(/^\+2 project_activity\.graphs\.issues$/)).toBeInTheDocument(); + expect(screen.getByText(/^-1\.0% project_activity\.graphs\.coverage$/)).toBeInTheDocument(); + // Analysis 3 + expect(screen.getByText(/^-100 project_activity\.graphs\.issues$/)).toBeInTheDocument(); + expect(screen.getByText(/^\+15\.2% project_activity\.graphs\.coverage$/)).toBeInTheDocument(); + expect(screen.getByText(/^-1\.5% project_activity\.graphs\.duplications$/)).toBeInTheDocument(); + // Analysis 4 (first one) + expect(screen.getByText(/^502 project_activity\.graphs\.issues$/)).toBeInTheDocument(); + expect(screen.getByText(/^0\.0% project_activity\.graphs\.coverage$/)).toBeInTheDocument(); + expect(screen.getByText(/^10\.0% project_activity\.graphs\.duplications$/)).toBeInTheDocument(); }); function renderActivityPanel(props: Partial = {}) { - const mockedMeasureHistory = [mockMeasureHistory()]; + const mockedMeasureHistory = [ + mockMeasureHistory({ + metric: MetricKey.code_smells, + history: [ + mockHistoryItem({ date: parseDate('2018-10-27T10:21:15+0200'), value: '500' }), + mockHistoryItem({ date: parseDate('2018-10-27T12:21:15+0200'), value: '400' }), + mockHistoryItem({ date: parseDate('2020-10-27T16:33:50+0200'), value: '400' }), + mockHistoryItem({ date: parseDate('2020-10-27T18:33:50+0200'), value: '400' }), + ], + }), + mockMeasureHistory({ + metric: MetricKey.bugs, + history: [ + mockHistoryItem({ date: parseDate('2018-10-27T10:21:15+0200'), value: '0' }), + mockHistoryItem({ date: parseDate('2018-10-27T12:21:15+0200'), value: '0' }), + mockHistoryItem({ date: parseDate('2020-10-27T16:33:50+0200'), value: '2' }), + mockHistoryItem({ date: parseDate('2020-10-27T18:33:50+0200'), value: '0' }), + ], + }), + mockMeasureHistory({ + metric: MetricKey.vulnerabilities, + history: [ + mockHistoryItem({ date: parseDate('2018-10-27T10:21:15+0200'), value: '2' }), + mockHistoryItem({ date: parseDate('2018-10-27T12:21:15+0200'), value: '2' }), + mockHistoryItem({ date: parseDate('2020-10-27T16:33:50+0200'), value: '2' }), + mockHistoryItem({ date: parseDate('2020-10-27T18:33:50+0200'), value: '4' }), + ], + }), + mockMeasureHistory({ + metric: MetricKey.duplicated_lines_density, + history: [ + mockHistoryItem({ date: parseDate('2018-10-27T10:21:15+0200'), value: '10.0' }), + mockHistoryItem({ date: parseDate('2018-10-27T12:21:15+0200'), value: '8.5' }), + mockHistoryItem({ date: parseDate('2020-10-27T16:33:50+0200'), value: '8.5' }), + mockHistoryItem({ date: parseDate('2020-10-27T18:33:50+0200'), value: '15.0' }), + ], + }), + mockMeasureHistory({ + metric: MetricKey.coverage, + history: [ + mockHistoryItem({ date: parseDate('2018-10-27T10:21:15+0200'), value: '0.0' }), + mockHistoryItem({ date: parseDate('2018-10-27T12:21:15+0200'), value: '15.2' }), + mockHistoryItem({ date: parseDate('2020-10-27T16:33:50+0200'), value: '14.2' }), + mockHistoryItem({ date: parseDate('2020-10-27T18:33:50+0200'), value: '14.2' }), + ], + }), + ]; const mockedMetrics = [mockMetric()]; const mockedAnalysis = [ mockAnalysis({ @@ -83,6 +150,8 @@ function renderActivityPanel(props: Partial = {}) { ], }), mockAnalysis({ key: 'bar' }), + mockAnalysis(), + mockAnalysis(), ]; const mockedProps: ActivityPanelProps = { diff --git a/server/sonar-web/src/main/js/apps/overview/utils.ts b/server/sonar-web/src/main/js/apps/overview/utils.ts index e42daa22931..195386a2a1a 100644 --- a/server/sonar-web/src/main/js/apps/overview/utils.ts +++ b/server/sonar-web/src/main/js/apps/overview/utils.ts @@ -25,6 +25,7 @@ import { translate } from '../../helpers/l10n'; import { parseAsString } from '../../helpers/query'; import { IssueType } from '../../types/issues'; import { MetricKey } from '../../types/metrics'; +import { AnalysisMeasuresVariations, MeasureHistory } from '../../types/project-activity'; import { RawQuery } from '../../types/types'; export const METRICS: string[] = [ @@ -107,6 +108,14 @@ export const HISTORY_METRICS_LIST: string[] = [ MetricKey.coverage, ]; +const MEASURES_VARIATIONS_METRICS = [ + MetricKey.bugs, + MetricKey.code_smells, + MetricKey.coverage, + MetricKey.duplicated_lines_density, + MetricKey.vulnerabilities, +]; + export enum MeasurementType { Coverage = 'COVERAGE', Duplication = 'DUPLICATION', @@ -187,3 +196,39 @@ export const parseQuery = memoize((urlQuery: RawQuery): { codeScope: string } => codeScope: parseAsString(urlQuery['code_scope']), }; }); + +export function getAnalysisVariations(measures: MeasureHistory[], analysesCount: number) { + if (analysesCount === 0) { + return []; + } + + const emptyVariations: AnalysisMeasuresVariations[] = Array.from( + { length: analysesCount }, + () => ({}), + ); + + return measures.reduce((variations, { metric, history }) => { + if (!MEASURES_VARIATIONS_METRICS.includes(metric)) { + return variations; + } + + history.slice(-analysesCount).forEach(({ value = '' }, index, analysesHistory) => { + if (index === 0) { + variations[index][metric] = parseFloat(value) || 0; + return; + } + + const previousValue = parseFloat(analysesHistory[index - 1].value ?? '') || 0; + const numericValue = parseFloat(value) || 0; + const variation = numericValue - previousValue; + + if (variation === 0) { + return; + } + + variations[index][metric] = variation; + }); + + return variations; + }, emptyVariations); +} diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/GraphsTooltips-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/GraphsTooltips-it.tsx index e9a46c4a7fe..ecd36935267 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/GraphsTooltips-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/GraphsTooltips-it.tsx @@ -78,18 +78,20 @@ function renderGraphsTooltips(props: Partial = {}) { const date = props.selectedDate || parseDate('2016-01-01T00:00:00+0200'); const metrics: Metric[] = []; - [ - [MetricKey.bugs, '1'], - [MetricKey.reliability_rating, '3'], - [MetricKey.code_smells, '0'], - [MetricKey.sqale_rating, '1'], - [MetricKey.vulnerabilities, '2'], - [MetricKey.security_rating, '5'], - [MetricKey.lines_to_cover, '10'], - [MetricKey.uncovered_lines, '8'], - [MetricKey.coverage, '75'], - [MetricKey.duplicated_lines_density, '3'], - ].forEach(([metric, value]) => { + ( + [ + [MetricKey.bugs, '1'], + [MetricKey.reliability_rating, '3'], + [MetricKey.code_smells, '0'], + [MetricKey.sqale_rating, '1'], + [MetricKey.vulnerabilities, '2'], + [MetricKey.security_rating, '5'], + [MetricKey.lines_to_cover, '10'], + [MetricKey.uncovered_lines, '8'], + [MetricKey.coverage, '75'], + [MetricKey.duplicated_lines_density, '3'], + ] as Array<[MetricKey, string]> + ).forEach(([metric, value]) => { measuresHistory.push( mockMeasureHistory({ metric, diff --git a/server/sonar-web/src/main/js/types/project-activity.ts b/server/sonar-web/src/main/js/types/project-activity.ts index 55d66388e08..1a82a555ea0 100644 --- a/server/sonar-web/src/main/js/types/project-activity.ts +++ b/server/sonar-web/src/main/js/types/project-activity.ts @@ -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 { MetricKey } from './metrics'; import { Status } from './types'; interface BaseAnalysis { @@ -91,7 +92,7 @@ export interface HistoryItem { } export interface MeasureHistory { - metric: string; + metric: MetricKey; history: HistoryItem[]; } @@ -106,3 +107,5 @@ export interface Point { x: Date; y: number | string | undefined; } + +export type AnalysisMeasuresVariations = Partial>; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index d7cce749480..b6c166ee701 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3879,6 +3879,9 @@ overview.badges.renew.description=If your project badge security token has leake overview.quality_profiles_update_after_sq_upgrade.message=Upgrade to SonarQube {sqVersion} has updated your Quality Profiles. Issues on your project may have been affected. {link} overview.quality_profiles_update_after_sq_upgrade.link=See more details +overview.activity.variations.new_analysis=New analysis: +overview.activity.variations.first_analysis=First analysis: + #------------------------------------------------------------------------------ #