From b70ce44a8dd8d1411bdbe9774e72176c8954b9bc Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Fri, 16 Jun 2017 09:53:28 +0200 Subject: [PATCH] SONAR-9401 Add the coverage graph to the project activity page --- .../js/app/components/ProjectContainer.js | 2 +- .../js/apps/overview/events/AnalysesList.js | 12 ++- .../projectActivity/__tests__/actions-test.js | 46 ++++++---- .../components/ProjectActivityAnalysesList.js | 80 ++++++++--------- .../components/ProjectActivityApp.js | 86 ++++++++++--------- .../components/ProjectActivityGraphs.js | 24 +++--- .../components/StaticGraphs.js | 56 +++++++----- .../components/StaticGraphsLegend.js | 5 +- .../components/projectActivity.css | 3 - .../src/main/js/apps/projectActivity/utils.js | 39 +++++++-- .../js/components/charts/AdvancedTimeline.js | 30 +++++-- .../main/js/components/common/ResizeHelper.js | 75 ---------------- .../src/main/less/components/graphics.less | 22 ++++- .../resources/org/sonar/l10n/core.properties | 3 + 14 files changed, 250 insertions(+), 233 deletions(-) delete mode 100644 server/sonar-web/src/main/js/components/common/ResizeHelper.js diff --git a/server/sonar-web/src/main/js/app/components/ProjectContainer.js b/server/sonar-web/src/main/js/app/components/ProjectContainer.js index 992aa695e7f..472f974fa03 100644 --- a/server/sonar-web/src/main/js/app/components/ProjectContainer.js +++ b/server/sonar-web/src/main/js/app/components/ProjectContainer.js @@ -56,7 +56,7 @@ class ProjectContainer extends React.PureComponent { fetchProject() { this.props.fetchProject(this.props.location.query.id).catch(e => { - if (e.response.status === 403) { + if (e.response && e.response.status === 403) { handleRequiredAuthorization(); } else { parseError(e).then(message => this.props.addGlobalErrorMessage(message)); diff --git a/server/sonar-web/src/main/js/apps/overview/events/AnalysesList.js b/server/sonar-web/src/main/js/apps/overview/events/AnalysesList.js index 6500ae188be..664a9b41b50 100644 --- a/server/sonar-web/src/main/js/apps/overview/events/AnalysesList.js +++ b/server/sonar-web/src/main/js/apps/overview/events/AnalysesList.js @@ -59,13 +59,11 @@ export default class AnalysesList extends React.PureComponent { fetchData() { this.setState({ loading: true }); - getProjectActivity({ project: this.props.project, ps: PAGE_SIZE }) - .then(({ analyses }) => { - if (this.mounted) { - this.setState({ analyses, loading: false }); - } - }) - .catch(throwGlobalError); + getProjectActivity({ project: this.props.project, ps: PAGE_SIZE }).then(({ analyses }) => { + if (this.mounted) { + this.setState({ analyses, loading: false }); + } + }, throwGlobalError); } renderList(analyses: Array) { diff --git a/server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.js b/server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.js index 896a172c9a4..5ac56316836 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.js @@ -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. */ +// @flow import * as actions from '../actions'; const ANALYSES = [ @@ -60,47 +61,56 @@ const newEvent = { category: 'Custom' }; +const emptyState = { + analyses: [], + loading: false, + measuresHistory: [], + measures: [], + metrics: [], + query: { category: '', graph: '', project: '' } +}; + +const state = { ...emptyState, analyses: ANALYSES }; + it('should never throw when there is no analyses', () => { - expect(actions.addCustomEvent('A1', newEvent)({})).toBeUndefined(); - expect(actions.deleteEvent('A1', newEvent)({})).toBeUndefined(); - expect(actions.changeEvent('A1', newEvent)({})).toBeUndefined(); - expect(actions.deleteAnalysis('Anew')({})).toBeUndefined(); + expect(actions.addCustomEvent('A1', newEvent)(emptyState).analyses).toHaveLength(0); + expect(actions.deleteEvent('A1', 'Enew')(emptyState).analyses).toHaveLength(0); + expect(actions.changeEvent('A1', newEvent)(emptyState).analyses).toHaveLength(0); + expect(actions.deleteAnalysis('Anew')(emptyState).analyses).toHaveLength(0); }); describe('addCustomEvent', () => { it('should correctly add a custom event', () => { - expect( - actions.addCustomEvent('A2', newEvent)({ analyses: ANALYSES }).analyses[1] - ).toMatchSnapshot(); - expect( - actions.addCustomEvent('A1', newEvent)({ analyses: ANALYSES }).analyses[0].events - ).toContain(newEvent); + expect(actions.addCustomEvent('A2', newEvent)(state).analyses[1]).toMatchSnapshot(); + expect(actions.addCustomEvent('A1', newEvent)(state).analyses[0].events).toContain(newEvent); }); }); describe('deleteEvent', () => { it('should correctly remove an event', () => { - expect(actions.deleteEvent('A1', 'E1')({ analyses: ANALYSES }).analyses[0]).toMatchSnapshot(); - expect(actions.deleteEvent('A2', 'E1')({ analyses: ANALYSES }).analyses[1]).toMatchSnapshot(); - expect(actions.deleteEvent('A3', 'E2')({ analyses: ANALYSES }).analyses[2]).toMatchSnapshot(); + expect(actions.deleteEvent('A1', 'E1')(state).analyses[0]).toMatchSnapshot(); + expect(actions.deleteEvent('A2', 'E1')(state).analyses[1]).toMatchSnapshot(); + expect(actions.deleteEvent('A3', 'E2')(state).analyses[2]).toMatchSnapshot(); }); }); describe('changeEvent', () => { it('should correctly update an event', () => { expect( - actions.changeEvent('A1', { key: 'E1', name: 'changed' })({ analyses: ANALYSES }).analyses[0] + actions.changeEvent('A1', { key: 'E1', name: 'changed', category: 'VERSION' })(state) + .analyses[0] ).toMatchSnapshot(); expect( - actions.changeEvent('A2', { key: 'E2' })({ analyses: ANALYSES }).analyses[1].events + actions.changeEvent('A2', { key: 'E2', name: 'foo', category: 'VERSION' })(state).analyses[1] + .events ).toHaveLength(0); }); }); describe('deleteAnalysis', () => { it('should correctly delete an analyses', () => { - expect(actions.deleteAnalysis('A1')({ analyses: ANALYSES }).analyses).toMatchSnapshot(); - expect(actions.deleteAnalysis('A5')({ analyses: ANALYSES }).analyses).toHaveLength(3); - expect(actions.deleteAnalysis('A2')({ analyses: ANALYSES }).analyses).toHaveLength(2); + expect(actions.deleteAnalysis('A1')(state).analyses).toMatchSnapshot(); + expect(actions.deleteAnalysis('A5')(state).analyses).toHaveLength(3); + expect(actions.deleteAnalysis('A2')(state).analyses).toHaveLength(2); }); }); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js index b74b7179090..91064da44f7 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js @@ -32,20 +32,22 @@ type Props = { addVersion: (analysis: string, version: string) => Promise<*>, analyses: Array, canAdmin: boolean, + className?: string, changeEvent: (event: string, name: string) => Promise<*>, deleteAnalysis: (analysis: string) => Promise<*>, deleteEvent: (analysis: string, event: string) => Promise<*>, fetchMoreActivity: () => void, + loading: boolean, paging?: Paging }; export default function ProjectActivityAnalysesList(props: Props) { if (props.analyses.length === 0) { return ( -
-
-
{translate('no_results')}
-
+
+ {props.loading + ?
+ : {translate('no_results')}}
); } @@ -53,44 +55,42 @@ export default function ProjectActivityAnalysesList(props: Props) { const firstAnalysis = props.analyses[0]; const byDay = groupBy(props.analyses, analysis => moment(analysis.date).startOf('day').valueOf()); return ( -
-
-
    - {Object.keys(byDay).map(day => ( -
  • -
    - -
    +
    +
      + {Object.keys(byDay).map(day => ( +
    • +
      + +
      -
        - {byDay[day] != null && - byDay[day].map(analysis => ( - - ))} -
      -
    • - ))} -
    +
      + {byDay[day] != null && + byDay[day].map(analysis => ( + + ))} +
    +
  • + ))} +
- -
+
); } diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js index b5e2589e567..0435340ddf4 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js @@ -101,17 +101,17 @@ export default class ProjectActivityApp extends React.PureComponent { fetchMetrics = (): Promise> => getMetrics().catch(throwGlobalError); fetchMeasuresHistory = (metrics: Array): Promise> => - getAllTimeMachineData(this.props.project.key, metrics) - .then(({ measures }) => + getAllTimeMachineData(this.props.project.key, metrics).then( + ({ measures }) => measures.map(measure => ({ metric: measure.metric, history: measure.history.map(analysis => ({ date: moment(analysis.date).toDate(), value: analysis.value })) - })) - ) - .catch(throwGlobalError); + })), + throwGlobalError + ); fetchMoreActivity = () => { const { paging, query } = this.state; @@ -136,9 +136,9 @@ export default class ProjectActivityApp extends React.PureComponent { .createEvent(analysis, name, category) .then( ({ analysis, ...event }) => - this.mounted && this.setState(actions.addCustomEvent(analysis, event)) - ) - .catch(throwGlobalError); + this.mounted && this.setState(actions.addCustomEvent(analysis, event)), + throwGlobalError + ); addVersion = (analysis: string, version: string): Promise<*> => this.addCustomEvent(analysis, version, 'VERSION'); @@ -146,23 +146,27 @@ export default class ProjectActivityApp extends React.PureComponent { deleteEvent = (analysis: string, event: string): Promise<*> => api .deleteEvent(event) - .then(() => this.mounted && this.setState(actions.deleteEvent(analysis, event))) - .catch(throwGlobalError); + .then( + () => this.mounted && this.setState(actions.deleteEvent(analysis, event)), + throwGlobalError + ); changeEvent = (event: string, name: string): Promise<*> => api .changeEvent(event, name) .then( ({ analysis, ...event }) => - this.mounted && this.setState(actions.changeEvent(analysis, event)) - ) - .catch(throwGlobalError); + this.mounted && this.setState(actions.changeEvent(analysis, event)), + throwGlobalError + ); deleteAnalysis = (analysis: string): Promise<*> => api .deleteAnalysis(analysis) - .then(() => this.mounted && this.setState(actions.deleteAnalysis(analysis))) - .catch(throwGlobalError); + .then( + () => this.mounted && this.setState(actions.deleteAnalysis(analysis)), + throwGlobalError + ); getMetricType = () => { const metricKey = GRAPHS_METRICS[this.state.query.graph][0]; @@ -206,10 +210,9 @@ export default class ProjectActivityApp extends React.PureComponent { }; render() { - const { query } = this.state; + const { analyses, loading, query } = this.state; const { configuration } = this.props.project; const canAdmin = configuration ? configuration.showHistory : false; - return (
@@ -217,28 +220,33 @@ export default class ProjectActivityApp extends React.PureComponent {
- - - +
+ +
+
+ +
); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js index b824565b302..33413850128 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js @@ -36,19 +36,19 @@ type Props = { }; export default function ProjectActivityGraphs(props: Props) { + const { graph } = props.query; return ( -
-
- - -
+
+ +
); } diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js index 92966f7bad3..98bb977ff12 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js @@ -20,10 +20,11 @@ import React from 'react'; import moment from 'moment'; import { some, sortBy } from 'lodash'; +import { AutoSizer } from 'react-virtualized'; import AdvancedTimeline from '../../../components/charts/AdvancedTimeline'; import StaticGraphsLegend from './StaticGraphsLegend'; -import ResizeHelper from '../../../components/common/ResizeHelper'; import { formatMeasure, getShortType } from '../../../helpers/measures'; +import { generateCoveredLinesMetric } from '../utils'; import { translate } from '../../../helpers/l10n'; import type { Analysis, MeasureHistory } from '../types'; @@ -56,13 +57,22 @@ export default class StaticGraphs extends React.PureComponent { }; getSeries = () => - sortBy(this.props.measuresHistory, 'metric').map(measure => ({ - name: measure.metric, - data: measure.history.map(analysis => ({ - x: analysis.date, - y: this.props.metricsType === 'LEVEL' ? analysis.value : Number(analysis.value) - })) - })); + sortBy( + this.props.measuresHistory.map(measure => { + if (measure.metric === 'uncovered_lines') { + return generateCoveredLinesMetric(measure, this.props.measuresHistory); + } + return { + name: measure.metric, + translatedName: translate('metric', measure.metric, 'name'), + data: measure.history.map(analysis => ({ + x: analysis.date, + y: this.props.metricsType === 'LEVEL' ? analysis.value : Number(analysis.value) + })) + }; + }), + 'name' + ); hasHistoryData = () => some(this.props.measuresHistory, measure => measure.history && measure.history.length > 2); @@ -95,19 +105,23 @@ export default class StaticGraphs extends React.PureComponent {
- - - + + {({ height, width }) => ( + + )} +
); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js index 9f76e8cde43..0a1b1040aa0 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js @@ -20,10 +20,9 @@ import React from 'react'; import classNames from 'classnames'; import ChartLegendIcon from '../../../components/icons-components/ChartLegendIcon'; -import { translate } from '../../../helpers/l10n'; type Props = { - series: Array<{ name: string }> + series: Array<{ name: string, translatedName: string }> }; export default function StaticGraphsLegend({ series }: Props) { @@ -34,7 +33,7 @@ export default function StaticGraphsLegend({ series }: Props) { - {translate('metric', serie.name, 'name')} + {serie.translatedName} ))}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css b/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css index a9dc2009ddb..038963a632b 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css @@ -6,9 +6,6 @@ .project-activity-page-side-outer { width: 400px; overflow: auto; -} - -.project-activity-page-side-outer .boxed-group { margin-bottom: 0; } diff --git a/server/sonar-web/src/main/js/apps/projectActivity/utils.js b/server/sonar-web/src/main/js/apps/projectActivity/utils.js index be198ce067d..37da896d9d2 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/utils.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.js @@ -19,15 +19,26 @@ */ // @flow import { cleanQuery, parseAsString, serializeString } from '../../helpers/query'; -import type { Query } from './types'; +import { translate } from '../../helpers/l10n'; +import type { MeasureHistory, Query } from './types'; import type { RawQuery } from '../../helpers/query'; -export const GRAPH_TYPES = ['overview']; -export const GRAPHS_METRICS = { overview: ['bugs', 'vulnerabilities', 'code_smells'] }; +export const GRAPH_TYPES = ['overview', 'coverage']; +export const GRAPHS_METRICS = { + overview: ['bugs', 'vulnerabilities', 'code_smells'], + coverage: ['uncovered_lines', 'lines_to_cover'] +}; + +const parseGraph = (value?: string): string => { + const graph = parseAsString(value); + return GRAPH_TYPES.includes(graph) ? graph : 'overview'; +}; + +const serializeGraph = (value: string): string => (value === 'overview' ? '' : value); export const parseQuery = (urlQuery: RawQuery): Query => ({ category: parseAsString(urlQuery['category']), - graph: parseAsString(urlQuery['graph']) || 'overview', + graph: parseGraph(urlQuery['graph']), project: parseAsString(urlQuery['id']) }); @@ -38,10 +49,26 @@ export const serializeQuery = (query: Query): RawQuery => }); export const serializeUrlQuery = (query: Query): RawQuery => { - const graph = query.graph === 'overview' ? '' : query.graph; return cleanQuery({ category: serializeString(query.category), - graph: serializeString(graph), + graph: serializeGraph(query.graph), id: serializeString(query.project) }); }; + +export const generateCoveredLinesMetric = ( + uncoveredLines: MeasureHistory, + measuresHistory: Array +) => { + const linesToCover = measuresHistory.find(measure => measure.metric === 'lines_to_cover'); + return { + name: 'covered_lines', + translatedName: translate('project_activity.custom_metric.covered_lines'), + data: linesToCover + ? uncoveredLines.history.map((analysis, idx) => ({ + x: analysis.date, + y: Number(linesToCover.history[idx].value) - Number(analysis.value) + })) + : [] + }; +}; diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js index a7cfa6b848c..86921f46fed 100644 --- a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js @@ -23,7 +23,7 @@ import classNames from 'classnames'; import { flatten } from 'lodash'; import { extent, max } from 'd3-array'; import { scaleLinear, scalePoint, scaleTime } from 'd3-scale'; -import { line as d3Line, curveBasis } from 'd3-shape'; +import { line as d3Line, area, curveBasis } from 'd3-shape'; type Point = { x: Date, y: number | string }; @@ -43,7 +43,8 @@ type Props = { width: number, leakPeriodDate: Date, padding: Array, - series: Array + series: Array, + showAreas?: boolean }; export default class AdvancedTimeline extends React.PureComponent { @@ -158,9 +159,9 @@ export default class AdvancedTimeline extends React.PureComponent { }; renderLines = (xScale: Scale, yScale: Scale) => { - const line = d3Line().x(d => xScale(d.x)).y(d => yScale(d.y)); + const lineGenerator = d3Line().x(d => xScale(d.x)).y(d => yScale(d.y)); if (this.props.basisCurve) { - line.curve(curveBasis); + lineGenerator.curve(curveBasis); } return ( @@ -168,7 +169,25 @@ export default class AdvancedTimeline extends React.PureComponent { + ))} + + ); + }; + + renderAreas = (xScale: Scale, yScale: Scale) => { + const areaGenerator = area().x(d => xScale(d.x)).y1(d => yScale(d.y)).y0(yScale(0)); + if (this.props.basisCurve) { + areaGenerator.curve(curveBasis); + } + return ( + + {this.props.series.map((serie, idx) => ( + ))} @@ -208,6 +227,7 @@ export default class AdvancedTimeline extends React.PureComponent { {this.renderLeak(xScale, yScale)} {this.renderHorizontalGrid(xScale, yScale)} {this.renderTicks(xScale, yScale)} + {this.props.showAreas && this.renderAreas(xScale, yScale)} {this.renderLines(xScale, yScale)} {this.renderEvents(xScale, yScale)} diff --git a/server/sonar-web/src/main/js/components/common/ResizeHelper.js b/server/sonar-web/src/main/js/components/common/ResizeHelper.js deleted file mode 100644 index ec51c1c9c5e..00000000000 --- a/server/sonar-web/src/main/js/components/common/ResizeHelper.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2017 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. - */ -// @flow -import React from 'react'; -import ReactDOM from 'react-dom'; - -type Props = { - children: React.Element<*>, - height?: number, - width?: number -}; - -type State = { - height?: number, - width?: number -}; - -export default class ResizeHelper extends React.PureComponent { - props: Props; - state: State; - - constructor(props: Props) { - super(props); - this.state = { height: props.height, width: props.width }; - } - - componentDidMount() { - if (this.isResizable()) { - this.handleResize(); - window.addEventListener('resize', this.handleResize); - } - } - - componentWillUnmount() { - if (this.isResizable()) { - window.removeEventListener('resize', this.handleResize); - } - } - - isResizable = () => { - return !this.props.width || !this.props.height; - }; - - handleResize = () => { - const domNode = ReactDOM.findDOMNode(this); - if (domNode && domNode.parentElement) { - const boundingClientRect = domNode.parentElement.getBoundingClientRect(); - this.setState({ width: boundingClientRect.width, height: boundingClientRect.height }); - } - }; - - render() { - return React.cloneElement(this.props.children, { - width: this.props.width || this.state.width, - height: this.props.height || this.state.height - }); - } -} diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less index 3c411450c52..f4422ba4322 100644 --- a/server/sonar-web/src/main/less/components/graphics.less +++ b/server/sonar-web/src/main/less/components/graphics.less @@ -106,10 +106,9 @@ /* * Line Chart */ - @defaultSerieColor: @darkBlue; @serieColor1: @blue; -@serieColor2: #26adff; +@serieColor2: #24c6e0; .line-chart { } @@ -120,12 +119,29 @@ stroke-width: 2px; &.line-chart-path-1 { - stroke: @serieColor1 + stroke: @serieColor1; } &.line-chart-path-2 { stroke: @serieColor2; } + + &:hover { + z-index: 120; + } +} + +.line-chart-area { + fill: fade(@defaultSerieColor, 30%); + stroke-width: 0; + + &.line-chart-area-1 { + fill: fade(@serieColor1, 30%); + } + + &.line-chart-area-2 { + fill: fade(@serieColor2, 30%); + } } .line-chart-legend { 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 675ef106a6d..86400201fbb 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1284,6 +1284,9 @@ project_activity.delete_analysis.question=Are you sure you want to delete this a project_activity.filter_events=Filter events project_activity.graphs.overview=Overview +project_activity.graphs.coverage=Coverage + +project_activity.custom_metric.covered_lines=Covered Lines project_history.col.year=Year project_history.col.month=Month -- 2.39.5