diff options
7 files changed, 191 insertions, 56 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/ApplicationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ApplicationServiceMock.ts new file mode 100644 index 00000000000..2247d82cb71 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/ApplicationServiceMock.ts @@ -0,0 +1,50 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { getApplicationLeak } from '../application'; + +jest.mock('../application'); + +export default class ApplicationServiceMock { + constructor() { + jest.mocked(getApplicationLeak).mockImplementation(this.handleGetApplicationLeak); + } + + handleGetApplicationLeak = () => { + return this.reply([ + { + project: 'org.sonarsource.scanner.cli:sonar-scanner-cli', + projectName: 'SonarScanner CLI', + date: '2022-12-23T11:02:26+0100', + }, + { + project: 'org.sonarsource.scanner.maven:sonar-maven-plugin', + projectName: 'SonarQube Scanner for Maven', + date: '2021-11-09T13:59:13+0100', + }, + ]); + }; + + reset = () => {}; + + reply<T>(response: T): Promise<T> { + return Promise.resolve(cloneDeep(response)); + } +} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx index 555837b63b6..c9a199e4b27 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx @@ -19,13 +19,14 @@ */ import * as React from 'react'; import { useSearchParams } from 'react-router-dom'; +import { getApplicationLeak } from '../../../api/application'; import { + ProjectActivityStatuses, changeEvent, createEvent, deleteAnalysis, deleteEvent, getProjectActivity, - ProjectActivityStatuses, } from '../../../api/projectActivity'; import { getAllTimeMachineData } from '../../../api/time-machine'; import withComponentContext from '../../../app/components/componentContext/withComponentContext'; @@ -42,7 +43,12 @@ import { parseDate } from '../../../helpers/dates'; import { serializeStringArray } from '../../../helpers/query'; import { withBranchLikes } from '../../../queries/branch'; import { BranchLike } from '../../../types/branch-like'; -import { ComponentQualifier, isPortfolioLike } from '../../../types/component'; +import { + ComponentQualifier, + isApplication, + isPortfolioLike, + isProject, +} from '../../../types/component'; import { MetricKey } from '../../../types/metrics'; import { GraphType, @@ -53,9 +59,9 @@ import { import { Component, Dict, Metric, Paging, RawQuery } from '../../../types/types'; import * as actions from '../actions'; import { + Query, customMetricsChanged, parseQuery, - Query, serializeQuery, serializeUrlQuery, } from '../utils'; @@ -72,6 +78,7 @@ interface Props { export interface State { analyses: ParsedAnalysis[]; analysesLoading: boolean; + leakPeriodDate?: Date; graphLoading: boolean; initialized: boolean; measuresHistory: MeasureHistory[]; @@ -287,40 +294,53 @@ class ProjectActivityApp extends React.PureComponent<Props, State> { ); }; - firstLoadData(query: Query, component: Component) { + async firstLoadData(query: Query, component: Component) { const graphMetrics = getHistoryMetrics(query.graph || DEFAULT_GRAPH, query.customMetrics); const topLevelComponent = this.getTopLevelComponent(component); - Promise.all([ - this.fetchActivity( - topLevelComponent, - [ - ProjectActivityStatuses.STATUS_PROCESSED, - ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE, - ], - 1, - ACTIVITY_PAGE_SIZE_FIRST_BATCH, - serializeQuery(query), - ), - this.fetchMeasuresHistory(graphMetrics), - ]).then( - ([{ analyses }, measuresHistory]) => { - if (this.mounted) { - this.setState({ - analyses, - graphLoading: false, - initialized: true, - measuresHistory, - }); + try { + const [{ analyses }, measuresHistory, leaks] = await Promise.all([ + this.fetchActivity( + topLevelComponent, + [ + ProjectActivityStatuses.STATUS_PROCESSED, + ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE, + ], + 1, + ACTIVITY_PAGE_SIZE_FIRST_BATCH, + serializeQuery(query), + ), + this.fetchMeasuresHistory(graphMetrics), + component.qualifier === ComponentQualifier.Application + ? // eslint-disable-next-line local-rules/no-api-imports + getApplicationLeak(component.key) + : undefined, + ]); - this.fetchAllActivities(topLevelComponent); - } - }, - () => { - if (this.mounted) { - this.setState({ initialized: true, graphLoading: false }); + if (this.mounted) { + let leakPeriodDate; + if (isApplication(component.qualifier) && leaks?.length) { + [leakPeriodDate] = leaks + .map((leak) => parseDate(leak.date)) + .sort((d1, d2) => d2.getTime() - d1.getTime()); + } else if (isProject(component.qualifier) && component.leakPeriodDate) { + leakPeriodDate = parseDate(component.leakPeriodDate); } - }, - ); + + this.setState({ + analyses, + graphLoading: false, + initialized: true, + leakPeriodDate, + measuresHistory, + }); + + this.fetchAllActivities(topLevelComponent); + } + } catch (error) { + if (this.mounted) { + this.setState({ initialized: true, graphLoading: false }); + } + } } updateGraphData = (graph: GraphType, customMetrics: string[]) => { @@ -367,6 +387,7 @@ class ProjectActivityApp extends React.PureComponent<Props, State> { onDeleteAnalysis={this.handleDeleteAnalysis} onDeleteEvent={this.handleDeleteEvent} graphLoading={!this.state.initialized || this.state.graphLoading} + leakPeriodDate={this.state.leakPeriodDate} initializing={!this.state.initialized} measuresHistory={this.state.measuresHistory} metrics={metrics} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx index 7ccda994368..57080e0eda6 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx @@ -47,6 +47,7 @@ interface Props { onDeleteAnalysis: (analysis: string) => Promise<void>; onDeleteEvent: (analysis: string, event: string) => Promise<void>; graphLoading: boolean; + leakPeriodDate?: Date; initializing: boolean; project: Pick<Component, 'configuration' | 'key' | 'leakPeriodDate' | 'qualifier'>; metrics: Metric[]; @@ -63,6 +64,7 @@ export default function ProjectActivityAppRenderer(props: Props) { props.project.qualifier === ComponentQualifier.Application) && (configuration ? configuration.showHistory : false); const canDeleteAnalyses = configuration ? configuration.showHistory : false; + const leakPeriodDate = props.leakPeriodDate ? parseDate(props.leakPeriodDate) : undefined; return ( <main className="sw-p-5" id="project-activity"> <Suggestions suggestions="project_activity" /> @@ -92,9 +94,7 @@ export default function ProjectActivityAppRenderer(props: Props) { onDeleteAnalysis={props.onDeleteAnalysis} onDeleteEvent={props.onDeleteEvent} initializing={props.initializing} - leakPeriodDate={ - props.project.leakPeriodDate ? parseDate(props.project.leakPeriodDate) : undefined - } + leakPeriodDate={leakPeriodDate} project={props.project} query={query} onUpdateQuery={props.onUpdateQuery} @@ -103,9 +103,7 @@ export default function ProjectActivityAppRenderer(props: Props) { <StyledWrapper className="sw-col-span-8 sw-rounded-1"> <ProjectActivityGraphs analyses={analyses} - leakPeriodDate={ - props.project.leakPeriodDate ? parseDate(props.project.leakPeriodDate) : undefined - } + leakPeriodDate={leakPeriodDate} loading={props.graphLoading} measuresHistory={measuresHistory} metrics={props.metrics} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx index f2ad64252b7..3202be860fb 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx @@ -24,6 +24,7 @@ import { keyBy, times } from 'lodash'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { Route } from 'react-router-dom'; +import ApplicationServiceMock from '../../../../api/mocks/ApplicationServiceMock'; import { ProjectActivityServiceMock } from '../../../../api/mocks/ProjectActivityServiceMock'; import { TimeMachineServiceMock } from '../../../../api/mocks/TimeMachineServiceMock'; import { parseDate } from '../../../../helpers/dates'; @@ -56,11 +57,13 @@ jest.mock('../../../../helpers/storage', () => ({ save: jest.fn(), })); +const applicationHandler = new ApplicationServiceMock(); const projectActivityHandler = new ProjectActivityServiceMock(); const timeMachineHandler = new TimeMachineServiceMock(); beforeEach(() => { jest.clearAllMocks(); + applicationHandler.reset(); projectActivityHandler.reset(); timeMachineHandler.reset(); @@ -93,6 +96,56 @@ describe('rendering', () => { expect(ui.graphs.getAll().length).toBe(1); }); + it('should render new code legend for applications', async () => { + const { ui } = getPageObject(); + + renderProjectActivityAppContainer( + mockComponent({ + qualifier: ComponentQualifier.Application, + breadcrumbs: [ + { key: 'breadcrumb', name: 'breadcrumb', qualifier: ComponentQualifier.Application }, + ], + }), + ); + await ui.appLoaded(); + + expect(ui.newCodeLegend.get()).toBeInTheDocument(); + }); + + it('should render new code legend for projects', async () => { + const { ui } = getPageObject(); + + renderProjectActivityAppContainer( + mockComponent({ + qualifier: ComponentQualifier.Project, + breadcrumbs: [ + { key: 'breadcrumb', name: 'breadcrumb', qualifier: ComponentQualifier.Project }, + ], + leakPeriodDate: parseDate('2017-03-01T22:00:00.000Z').toDateString(), + }), + ); + await ui.appLoaded(); + + expect(ui.newCodeLegend.get()).toBeInTheDocument(); + }); + + it.each([ComponentQualifier.Portfolio, ComponentQualifier.SubPortfolio])( + 'should not render new code legend for %s', + async (qualifier) => { + const { ui } = getPageObject(); + + renderProjectActivityAppContainer( + mockComponent({ + qualifier, + breadcrumbs: [{ key: 'breadcrumb', name: 'breadcrumb', qualifier }], + }), + ); + await ui.appLoaded(); + + expect(ui.newCodeLegend.query()).not.toBeInTheDocument(); + }, + ); + it('should correctly show the baseline marker', async () => { const { ui } = getPageObject(); @@ -417,6 +470,9 @@ function getPageObject() { addMetricBtn: byRole('button', { name: 'project_activity.graphs.custom.add' }), metricCheckbox: (name: MetricKey) => byRole('checkbox', { name }), + // Graph legend. + newCodeLegend: byText('hotspot.filters.period.since_leak_period'), + // Filtering. categorySelect: byLabelText('project_activity.filter_events'), resetDatesBtn: byRole('button', { name: 'project_activity.reset_dates' }), diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx index 7f5c8b1c5f4..1016fbaf73d 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx @@ -106,9 +106,13 @@ export default class GraphHistory extends React.PureComponent<Props, State> { return ( <StyledGraphContainer className="sw-flex sw-flex-col sw-justify-center sw-items-stretch sw-grow sw-py-2"> {isCustom && this.props.removeCustomMetric ? ( - <GraphsLegendCustom removeMetric={this.props.removeCustomMetric} series={series} /> + <GraphsLegendCustom + leakPeriodDate={leakPeriodDate} + removeMetric={this.props.removeCustomMetric} + series={series} + /> ) : ( - <GraphsLegendStatic series={series} /> + <GraphsLegendStatic leakPeriodDate={leakPeriodDate} series={series} /> )} <div className="sw-flex-1"> diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendCustom.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendCustom.tsx index 8f4d13f3456..ac7accaf605 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendCustom.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendCustom.tsx @@ -27,12 +27,13 @@ import { GraphsLegendItem } from './GraphsLegendItem'; import { hasDataValues } from './utils'; export interface GraphsLegendCustomProps { + leakPeriodDate?: Date; removeMetric: (metric: string) => void; series: Serie[]; } export default function GraphsLegendCustom(props: GraphsLegendCustomProps) { - const { series } = props; + const { leakPeriodDate, series, removeMetric } = props; return ( <ul className="activity-graph-legends sw-flex sw-justify-center sw-items-center sw-pb-4"> @@ -44,7 +45,7 @@ export default function GraphsLegendCustom(props: GraphsLegendCustomProps) { index={idx} metric={serie.name} name={serie.translatedName} - removeMetric={props.removeMetric} + removeMetric={removeMetric} showWarning={!hasData} /> ); @@ -71,12 +72,14 @@ export default function GraphsLegendCustom(props: GraphsLegendCustomProps) { </li> ); })} - <li key={translate('hotspot.filters.period.since_leak_period')}> - <NewCodeLegend - className="sw-ml-3 sw-mr-4" - text={translate('hotspot.filters.period.since_leak_period')} - /> - </li> + {leakPeriodDate && ( + <li key={translate('hotspot.filters.period.since_leak_period')}> + <NewCodeLegend + className="sw-ml-3 sw-mr-4" + text={translate('hotspot.filters.period.since_leak_period')} + /> + </li> + )} </ul> ); } diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendStatic.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendStatic.tsx index 0c579043bee..a731413b701 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendStatic.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendStatic.tsx @@ -25,10 +25,11 @@ import { Serie } from '../../types/project-activity'; import { GraphsLegendItem } from './GraphsLegendItem'; export interface GraphsLegendStaticProps { + leakPeriodDate?: Date; series: Array<Pick<Serie, 'name' | 'translatedName'>>; } -export default function GraphsLegendStatic({ series }: GraphsLegendStaticProps) { +export default function GraphsLegendStatic({ series, leakPeriodDate }: GraphsLegendStaticProps) { return ( <ul className="activity-graph-legends sw-flex sw-justify-center sw-items-center sw-pb-4"> {series.map((serie, idx) => ( @@ -41,12 +42,14 @@ export default function GraphsLegendStatic({ series }: GraphsLegendStaticProps) /> </li> ))} - <li key={translate('hotspot.filters.period.since_leak_period')}> - <NewCodeLegend - className="sw-mr-2" - text={translate('hotspot.filters.period.since_leak_period')} - /> - </li> + {leakPeriodDate && ( + <li key={translate('hotspot.filters.period.since_leak_period')}> + <NewCodeLegend + className="sw-mr-2" + text={translate('hotspot.filters.period.since_leak_period')} + /> + </li> + )} </ul> ); } |