--- /dev/null
+/*
+ * 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');
--- /dev/null
+/*
+ * 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');
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';
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;
MeasureHistory,
} from '../../../types/project-activity';
import { Component, Metric } from '../../../types/types';
+import { getAnalysisVariations } from '../utils';
import Analysis from './Analysis';
export interface 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 (
<div className="sw-mt-8">
</Card>
<Card className="sw-mt-4" data-test="overview__activity-analyses">
<Spinner loading={loading}>
- {filteredAnalyses.length === 0 ? (
+ {displayedAnalyses.length === 0 ? (
<p>{translate('no_results')}</p>
) : (
- filteredAnalyses.map((analysis, index) => (
+ displayedAnalyses.map((analysis, index) => (
<div key={analysis.key}>
- <Analysis analysis={analysis} qualifier={component.qualifier} />
- {index !== filteredAnalyses.length - 1 && <BasicSeparator className="sw-my-3" />}
+ <Analysis
+ analysis={analysis}
+ isFirstAnalysis={index === analyses.length - 1}
+ qualifier={component.qualifier}
+ variations={analysisVariations[index]}
+ />
+ {index !== displayedAnalyses.length - 1 && <BasicSeparator className="sw-my-3" />}
</div>
))
)}
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<AnalysisProps>) {
+ const { analysis, isFirstAnalysis, qualifier, variations } = props;
+
const sortedEvents = sortBy(
analysis.events,
(event) => {
);
// use `TRK` for all components but applications
- const qualifier =
- props.qualifier === ComponentQualifier.Application
+ const displayedQualifier =
+ qualifier === ComponentQualifier.Application
? ComponentQualifier.Application
: ComponentQualifier.Project;
{sortedEvents.length > 0
? sortedEvents.map((event) => <Event event={event} key={event.key} />)
- : translate('project_activity.analyzed', qualifier)}
+ : translate('project_activity.analyzed', displayedQualifier)}
+
+ {qualifier === ComponentQualifier.Project && variations !== undefined && (
+ <AnalysisVariations isFirstAnalysis={isFirstAnalysis} variations={variations} />
+ )}
</div>
);
}
--- /dev/null
+/*
+ * 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<VariationProps>) {
+ const {
+ isGoodIfGrowing,
+ label,
+ showVariationIcon = true,
+ valueType = MetricType.Integer,
+ variation,
+ } = props;
+
+ if (variation === undefined) {
+ return null;
+ }
+
+ const formattedValue = formatMeasure(variation, valueType);
+
+ if (!showVariationIcon) {
+ return (
+ <span className="sw-flex sw-items-center sw-mx-2">
+ {formattedValue} {<FormattedMessage id={label} />}
+ </span>
+ );
+ }
+
+ let variationIcon = <EqualIconContainer className="sw-text-lg">=</EqualIconContainer>;
+
+ if (variation !== 0) {
+ const ArrowIcon = variation > 0 ? TrendUpIcon : TrendDownIcon;
+ const ArrowIconContainer =
+ variation > 0 === isGoodIfGrowing
+ ? CaYCCompliantIconContainer
+ : CaYCNonCompliantIconContainer;
+
+ variationIcon = (
+ <ArrowIconContainer>
+ <ArrowIcon width={20} />
+ </ArrowIconContainer>
+ );
+ }
+
+ const variationToDisplay = formattedValue.startsWith('-') ? formattedValue : `+${formattedValue}`;
+
+ return (
+ <span className="sw-flex sw-items-center sw-mx-1">
+ {variationIcon} {variationToDisplay} {<FormattedMessage id={label} />}
+ </span>
+ );
+}
+
+export function AnalysisVariations(props: Readonly<AnalysisVariationsProps>) {
+ 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 (
+ <div className="sw-flex sw-items-center sw-mt-1">
+ <FormattedMessage
+ id={
+ isFirstAnalysis
+ ? 'overview.activity.variations.first_analysis'
+ : 'overview.activity.variations.new_analysis'
+ }
+ />
+ <Variation
+ isGoodIfGrowing={false}
+ label="project_activity.graphs.issues"
+ showVariationIcon={!isFirstAnalysis}
+ variation={issuesVariation}
+ />
+ {coverageVariation !== undefined && <SeparatorContainer>•</SeparatorContainer>}
+ <Variation
+ isGoodIfGrowing
+ label="project_activity.graphs.coverage"
+ showVariationIcon={!isFirstAnalysis}
+ valueType={MetricType.Percent}
+ variation={coverageVariation}
+ />
+ {duplicationsVariation !== undefined && <SeparatorContainer>•</SeparatorContainer>}
+ <Variation
+ isGoodIfGrowing={false}
+ label="project_activity.graphs.duplications"
+ showVariationIcon={!isFirstAnalysis}
+ valueType={MetricType.Percent}
+ variation={duplicationsVariation}
+ />
+ </div>
+ );
+}
+
+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')};
+`;
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,
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<ActivityPanelProps> = {}) {
- 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({
],
}),
mockAnalysis({ key: 'bar' }),
+ mockAnalysis(),
+ mockAnalysis(),
];
const mockedProps: ActivityPanelProps = {
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[] = [
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',
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);
+}
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,
* 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 {
}
export interface MeasureHistory {
- metric: string;
+ metric: MetricKey;
history: HistoryItem[];
}
x: Date;
y: number | string | undefined;
}
+
+export type AnalysisMeasuresVariations = Partial<Record<MetricKey, number>>;
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:
+
#------------------------------------------------------------------------------
#