diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-06-23 17:34:10 +0200 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-07-04 14:15:34 +0200 |
commit | 3a604bda9aa574c291f764ca8791b9af91d66a67 (patch) | |
tree | 4e92e74a1c5fb3d9fa9c483fb3fb1b742b45ce68 | |
parent | 21cdccf048db95fdeae2bf7046c2f2d830d172f5 (diff) | |
download | sonarqube-3a604bda9aa574c291f764ca8791b9af91d66a67.tar.gz sonarqube-3a604bda9aa574c291f764ca8791b9af91d66a67.zip |
SONAR-9414 Display a preview of the project activity graph on the project page
22 files changed, 550 insertions, 151 deletions
diff --git a/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js b/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js index 04d85e28e95..b9071b92d74 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js +++ b/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.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 React from 'react'; import moment from 'moment'; import QualityGate from '../qualityGate/QualityGate'; @@ -26,73 +27,46 @@ import Coverage from '../main/Coverage'; import Duplications from '../main/Duplications'; import Meta from './../meta/Meta'; import { getMeasuresAndMeta } from '../../../api/measures'; -import { getTimeMachineData } from '../../../api/time-machine'; +import { getAllTimeMachineData } from '../../../api/time-machine'; import { enhanceMeasuresWithMetrics } from '../../../helpers/measures'; import { getLeakPeriod } from '../../../helpers/periods'; -import { ComponentType } from '../propTypes'; import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin'; +import { getGraph } from '../../../helpers/storage'; +import { METRICS, HISTORY_METRICS_LIST } from '../utils'; +import { GRAPHS_METRICS } from '../../projectActivity/utils'; +import type { Component, History, MeasuresList, Period } from '../types'; import '../styles.css'; -const METRICS = [ - // quality gate - 'alert_status', - 'quality_gate_details', - - // bugs - 'bugs', - 'new_bugs', - 'reliability_rating', - 'new_reliability_rating', - - // vulnerabilities - 'vulnerabilities', - 'new_vulnerabilities', - 'security_rating', - 'new_security_rating', - - // code smells - 'code_smells', - 'new_code_smells', - 'sqale_rating', - 'new_maintainability_rating', - 'sqale_index', - 'new_technical_debt', - - // coverage - 'coverage', - 'new_coverage', - 'new_lines_to_cover', - 'tests', - - // duplications - 'duplicated_lines_density', - 'new_duplicated_lines_density', - 'duplicated_blocks', - - // size - 'ncloc', - 'ncloc_language_distribution', - 'new_lines' -]; - -const HISTORY_METRICS_LIST = ['sqale_index', 'duplicated_lines_density', 'ncloc', 'coverage']; +type Props = { + component: Component +}; -export default class OverviewApp extends React.PureComponent { - static propTypes = { - component: ComponentType.isRequired - }; +type State = { + history?: History, + historyStartDate?: Date, + loading: boolean, + measures: MeasuresList, + periods?: Array<Period> +}; - state = { - loading: true +export default class OverviewApp extends React.PureComponent { + mounted: boolean; + props: Props; + state: State = { + loading: true, + measures: [] }; componentDidMount() { this.mounted = true; - document.querySelector('html').classList.add('dashboard-page'); + const domElement = document.querySelector('html'); + if (domElement) { + domElement.classList.add('dashboard-page'); + } this.loadMeasures(this.props.component.key).then(() => this.loadHistory(this.props.component)); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { if (this.props.component.key !== prevProps.component.key) { this.loadMeasures(this.props.component.key).then(() => this.loadHistory(this.props.component) @@ -102,10 +76,13 @@ export default class OverviewApp extends React.PureComponent { componentWillUnmount() { this.mounted = false; - document.querySelector('html').classList.remove('dashboard-page'); + const domElement = document.querySelector('html'); + if (domElement) { + domElement.classList.remove('dashboard-page'); + } } - loadMeasures(componentKey) { + loadMeasures(componentKey: string) { this.setState({ loading: true }); return getMeasuresAndMeta(componentKey, METRICS, { @@ -121,10 +98,11 @@ export default class OverviewApp extends React.PureComponent { }); } - loadHistory(component) { - return getTimeMachineData(component.key, HISTORY_METRICS_LIST).then(r => { + loadHistory(component: Component) { + const metrics = HISTORY_METRICS_LIST.concat(GRAPHS_METRICS[getGraph()]); + return getAllTimeMachineData(component.key, metrics).then(r => { if (this.mounted) { - const history = {}; + const history: History = {}; r.measures.forEach(measure => { const measureHistory = measure.history.map(analysis => ({ date: moment(analysis.date).toDate(), @@ -174,7 +152,7 @@ export default class OverviewApp extends React.PureComponent { </div> <div className="page-sidebar-fixed"> - <Meta component={component} measures={measures} /> + <Meta component={component} history={history} measures={measures} /> </div> </div> </div> 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 664a9b41b50..9b199036fb2 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 @@ -21,18 +21,23 @@ import React from 'react'; import { Link } from 'react-router'; import Analysis from './Analysis'; +import PreviewGraph from './PreviewGraph'; import throwGlobalError from '../../../app/utils/throwGlobalError'; +import { getMetrics } from '../../../api/metrics'; import { getProjectActivity } from '../../../api/projectActivity'; import { translate } from '../../../helpers/l10n'; import type { Analysis as AnalysisType } from '../../projectActivity/types'; +import type { History, Metric } from '../types'; type Props = { + history: History, project: string }; type State = { analyses: Array<AnalysisType>, - loading: boolean + loading: boolean, + metrics: Array<Metric> }; const PAGE_SIZE = 5; @@ -40,7 +45,7 @@ const PAGE_SIZE = 5; export default class AnalysesList extends React.PureComponent { mounted: boolean; props: Props; - state: State = { analyses: [], loading: true }; + state: State = { analyses: [], loading: true, metrics: [] }; componentDidMount() { this.mounted = true; @@ -59,9 +64,12 @@ export default class AnalysesList extends React.PureComponent { fetchData() { this.setState({ loading: true }); - getProjectActivity({ project: this.props.project, ps: PAGE_SIZE }).then(({ analyses }) => { + Promise.all([ + getProjectActivity({ project: this.props.project, ps: PAGE_SIZE }), + getMetrics() + ]).then(response => { if (this.mounted) { - this.setState({ analyses, loading: false }); + this.setState({ analyses: response[0].analyses, metrics: response[1], loading: false }); } }, throwGlobalError); } @@ -95,6 +103,12 @@ export default class AnalysesList extends React.PureComponent { {translate('project_activity.page')} </h4> + <PreviewGraph + history={this.props.history} + project={this.props.project} + metrics={this.state.metrics} + /> + {this.renderList(analyses)} <div className="spacer-top small"> diff --git a/server/sonar-web/src/main/js/apps/overview/events/Analysis.js b/server/sonar-web/src/main/js/apps/overview/events/Analysis.js index 959338bb2df..592c91de41b 100644 --- a/server/sonar-web/src/main/js/apps/overview/events/Analysis.js +++ b/server/sonar-web/src/main/js/apps/overview/events/Analysis.js @@ -17,15 +17,24 @@ * 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 Events from '../../projectActivity/components/Events'; +import { sortBy } from 'lodash'; +import Event from './Event'; import FormattedDate from '../../../components/ui/FormattedDate'; import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin'; import { translate } from '../../../helpers/l10n'; -import type { Analysis as AnalysisType } from '../../projectActivity/types'; +import type { Analysis as AnalysisType, Event as EventType } from '../../projectActivity/types'; export default function Analysis(props: { analysis: AnalysisType }) { const { analysis } = props; + const sortedEvents: Array<EventType> = sortBy( + analysis.events, + // versions first + (event: EventType) => (event.category === 'VERSION' ? 0 : 1), + // then the rest sorted by category + 'category' + ); return ( <TooltipsContainer> @@ -36,8 +45,10 @@ export default function Analysis(props: { analysis: AnalysisType }) { </strong> </div> - {analysis.events.length > 0 - ? <Events events={analysis.events} canAdmin={false} /> + {sortedEvents.length > 0 + ? <div className="project-activity-events"> + {sortedEvents.map(event => <Event event={event} key={event.key} />)} + </div> : <span className="note">{translate('project_activity.project_analyzed')}</span>} </li> </TooltipsContainer> diff --git a/server/sonar-web/src/main/js/apps/overview/events/Event.js b/server/sonar-web/src/main/js/apps/overview/events/Event.js new file mode 100644 index 00000000000..1d377ecd253 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/events/Event.js @@ -0,0 +1,44 @@ +/* + * 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 { TooltipsContainer } from '../../../components/mixins/tooltips-mixin'; +import type { Event as EventType } from '../../projectActivity/types'; +import { translate } from '../../../helpers/l10n'; + +export default function Event(props: { event: EventType }) { + const { event } = props; + + if (event.category === 'VERSION') { + return <span className="overview-analysis-event badge">{props.event.name}</span>; + } + + return ( + <div className="overview-analysis-event"> + <TooltipsContainer> + <span> + <span className="note">{translate('event.category', event.category)}:</span> + {' '} + <strong title={event.description} data-toggle="tooltip">{event.name}</strong> + </span> + </TooltipsContainer> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js b/server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js new file mode 100644 index 00000000000..db45d7e51b1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js @@ -0,0 +1,111 @@ +/* + * 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 { map } from 'lodash'; +import { Link } from 'react-router'; +import { AutoSizer } from 'react-virtualized'; +import { generateSeries, GRAPHS_METRICS } from '../../projectActivity/utils'; +import { getGraph } from '../../../helpers/storage'; +import AdvancedTimeline from '../../../components/charts/AdvancedTimeline'; +import type { Serie } from '../../../components/charts/AdvancedTimeline'; +import type { History, Metric } from '../types'; + +type Props = { + history: History, + metrics: Array<Metric>, + project: string +}; + +type State = { + graph: string, + metricsType: string, + series: Array<Serie> +}; + +export default class PreviewGraph extends React.PureComponent { + props: Props; + state: State; + + constructor(props: Props) { + super(props); + const graph = getGraph(); + const metricsType = this.getMetricType(props.metrics, graph); + this.state = { + graph, + metricsType, + series: this.getSeries(props.history, graph, metricsType) + }; + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.history !== this.props.history || nextProps.metrics !== this.props.metrics) { + const graph = getGraph(); + const metricsType = this.getMetricType(nextProps.metrics, graph); + this.setState({ + graph, + metricsType, + series: this.getSeries(nextProps.history, graph, metricsType) + }); + } + } + + getSeries = (history: History, graph: string, metricsType: string): Array<Serie> => { + const measureHistory = map(history, (item, key) => ({ + metric: key, + history: item.filter(p => p.value != null) + })).filter(item => GRAPHS_METRICS[graph].indexOf(item.metric) >= 0); + return generateSeries(measureHistory, graph, metricsType); + }; + + getMetricType = (metrics: Array<Metric>, graph: string) => { + const metricKey = GRAPHS_METRICS[graph][0]; + const metric = metrics.find(metric => metric.key === metricKey); + return metric ? metric.type : 'INT'; + }; + + render() { + return ( + <div className="big-spacer-bottom spacer-top"> + <Link + className="overview-analysis-graph" + to={{ pathname: '/project/activity', query: { id: this.props.project } }}> + <AutoSizer disableHeight={true}> + {({ width }) => ( + <AdvancedTimeline + endDate={null} + startDate={null} + height={80} + width={width} + hideGrid={true} + hideXAxis={true} + interpolate="linear" + metricType={this.state.metricsType} + padding={[4, 0, 4, 0]} + series={this.state.series} + showAreas={['coverage', 'duplications'].includes(this.state.graph)} + /> + )} + </AutoSizer> + </Link> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/Analysis-test.js b/server/sonar-web/src/main/js/apps/overview/events/__tests__/Analysis-test.js new file mode 100644 index 00000000000..7130ad20b1d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/events/__tests__/Analysis-test.js @@ -0,0 +1,35 @@ +/* + * 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. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import Analysis from '../Analysis'; + +const ANALYSIS = { + key: '1', + date: '2017-06-10T16:10:59+0200', + events: [ + { key: '1', category: 'OTHER', name: 'test' }, + { key: '2', category: 'VERSION', name: '6.5-SNAPSHOT' } + ] +}; + +it('should sort the events with version first', () => { + expect(shallow(<Analysis analysis={ANALYSIS} />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/Event-test.js b/server/sonar-web/src/main/js/apps/overview/events/__tests__/Event-test.js new file mode 100644 index 00000000000..ec2d975ad55 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/events/__tests__/Event-test.js @@ -0,0 +1,33 @@ +/* + * 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. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import Event from '../Event'; + +const EVENT = { key: '1', category: 'OTHER', name: 'test' }; +const VERSION = { key: '2', category: 'VERSION', name: '6.5-SNAPSHOT' }; + +it('should render an event correctly', () => { + expect(shallow(<Event event={EVENT} />)).toMatchSnapshot(); +}); + +it('should render a version correctly', () => { + expect(shallow(<Event event={VERSION} />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Analysis-test.js.snap b/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Analysis-test.js.snap new file mode 100644 index 00000000000..d7a40d0bf71 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Analysis-test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should sort the events with version first 1`] = ` +<TooltipsContainer> + <li + className="overview-analysis" + > + <div + className="small little-spacer-bottom" + > + <strong> + <FormattedDate + date="2017-06-10T16:10:59+0200" + format="LL" + /> + </strong> + </div> + <div + className="project-activity-events" + > + <Event + event={ + Object { + "category": "VERSION", + "key": "2", + "name": "6.5-SNAPSHOT", + } + } + /> + <Event + event={ + Object { + "category": "OTHER", + "key": "1", + "name": "test", + } + } + /> + </div> + </li> +</TooltipsContainer> +`; diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Event-test.js.snap b/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Event-test.js.snap new file mode 100644 index 00000000000..b04b1393fc8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Event-test.js.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render a version correctly 1`] = ` +<span + className="overview-analysis-event badge" +> + 6.5-SNAPSHOT +</span> +`; + +exports[`should render an event correctly 1`] = ` +<div + className="overview-analysis-event" +> + <TooltipsContainer> + <span> + <span + className="note" + > + event.category.OTHER + : + </span> + + <strong + data-toggle="tooltip" + > + test + </strong> + </span> + </TooltipsContainer> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/overview/meta/Meta.js b/server/sonar-web/src/main/js/apps/overview/meta/Meta.js index a18e1385f1b..3f8cd994a1e 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/Meta.js +++ b/server/sonar-web/src/main/js/apps/overview/meta/Meta.js @@ -29,7 +29,7 @@ import MetaSize from './MetaSize'; import MetaTags from './MetaTags'; import { areThereCustomOrganizations } from '../../../store/rootReducer'; -const Meta = ({ component, measures, areThereCustomOrganizations }) => { +const Meta = ({ component, history, measures, areThereCustomOrganizations }) => { const { qualifier, description, qualityProfiles, qualityGate } = component; const isProject = qualifier === 'TRK'; @@ -70,7 +70,7 @@ const Meta = ({ component, measures, areThereCustomOrganizations }) => { {shouldShowOrganizationKey && <MetaOrganizationKey component={component} />} - {isProject && <AnalysesList project={component.key} />} + {isProject && <AnalysesList project={component.key} history={history} />} </div> ); }; diff --git a/server/sonar-web/src/main/js/apps/overview/styles.css b/server/sonar-web/src/main/js/apps/overview/styles.css index 5ffdc19cd63..575ff4a859f 100644 --- a/server/sonar-web/src/main/js/apps/overview/styles.css +++ b/server/sonar-web/src/main/js/apps/overview/styles.css @@ -345,6 +345,27 @@ border-top: 1px solid #e6e6e6; } +.overview-analysis-graph { + display: block; + outline: none; + border: none; +} + +.overview-analysis-event {} + +.overview-analysis-event.badge { + vertical-align: middle; + padding: 4px 14px; + border-radius: 2px; + font-weight: bold; + font-size: 12px; + letter-spacing: 0; +} + +.overview-analysis-event + .overview-analysis-event { + margin-top: 4px; +} + /* * Other */ diff --git a/server/sonar-web/src/main/js/apps/overview/types.js b/server/sonar-web/src/main/js/apps/overview/types.js index af71d5ae70c..9cb99b9780c 100644 --- a/server/sonar-web/src/main/js/apps/overview/types.js +++ b/server/sonar-web/src/main/js/apps/overview/types.js @@ -18,12 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ //@flow + export type Component = { id: string, key: string, qualifier: string }; +export type History = { [string]: Array<{ date: Date, value: string }> }; + export type Metric = { key: string, name: string, diff --git a/server/sonar-web/src/main/js/apps/overview/utils.js b/server/sonar-web/src/main/js/apps/overview/utils.js new file mode 100644 index 00000000000..2ea31331be9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/utils.js @@ -0,0 +1,69 @@ +/* + * 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 + +export const METRICS = [ + // quality gate + 'alert_status', + 'quality_gate_details', + + // bugs + 'bugs', + 'new_bugs', + 'reliability_rating', + 'new_reliability_rating', + + // vulnerabilities + 'vulnerabilities', + 'new_vulnerabilities', + 'security_rating', + 'new_security_rating', + + // code smells + 'code_smells', + 'new_code_smells', + 'sqale_rating', + 'new_maintainability_rating', + 'sqale_index', + 'new_technical_debt', + + // coverage + 'coverage', + 'new_coverage', + 'new_lines_to_cover', + 'tests', + + // duplications + 'duplicated_lines_density', + 'new_duplicated_lines_density', + 'duplicated_blocks', + + // size + 'ncloc', + 'ncloc_language_distribution', + 'new_lines' +]; + +export const HISTORY_METRICS_LIST = [ + 'sqale_index', + 'duplicated_lines_density', + 'ncloc', + 'coverage' +]; diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/Events.js b/server/sonar-web/src/main/js/apps/projectActivity/components/Events.js index 6f2725388d9..0dd5f89c054 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/Events.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/Events.js @@ -19,31 +19,23 @@ */ // @flow import React from 'react'; -import { sortBy } from 'lodash'; import Event from './Event'; +import './projectActivity.css'; import type { Event as EventType } from '../types'; type Props = { - analysis: string, - canAdmin: boolean, - changeEvent: (event: string, name: string) => Promise<*>, - deleteEvent: (analysis: string, event: string) => Promise<*>, + analysis?: string, + canAdmin?: boolean, + changeEvent?: (event: string, name: string) => Promise<*>, + deleteEvent?: (analysis: string, event: string) => Promise<*>, events: Array<EventType>, - isFirst: boolean + isFirst?: boolean }; export default function Events(props: Props) { - const sortedEvents: Array<EventType> = sortBy( - props.events, - // versions last - (event: EventType) => (event.category === 'VERSION' ? 1 : 0), - // then the rest sorted by category - 'category' - ); - return ( <div className="project-activity-events"> - {sortedEvents.map(event => ( + {props.events.map(event => ( <Event analysis={props.analysis} canAdmin={props.canAdmin} 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 e6dd14181e0..c44ac7e4e79 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 @@ -83,6 +83,7 @@ export default function ProjectActivityAnalysesList(props: Props) { deleteEvent={props.deleteEvent} isFirst={analysis.key === firstAnalysisKey} key={analysis.key} + version={version.version} /> ))} </ul> diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js index 406f963f0a3..41133f90f55 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js @@ -30,19 +30,18 @@ type Props = { addCustomEvent: (analysis: string, name: string, category?: string) => Promise<*>, addVersion: (analysis: string, version: string) => Promise<*>, analysis: Analysis, + canAdmin: boolean, changeEvent: (event: string, name: string) => Promise<*>, deleteAnalysis: (analysis: string) => Promise<*>, deleteEvent: (analysis: string, event: string) => Promise<*>, isFirst: boolean, - canAdmin: boolean + version: ?string }; export default function ProjectActivityAnalysis(props: Props) { const { date, events } = props.analysis; const { isFirst, canAdmin } = props; - const version = events.find(event => event.category === 'VERSION'); - return ( <li className="project-activity-analysis clearfix"> <div className="project-activity-time spacer-right"> @@ -64,7 +63,7 @@ export default function ProjectActivityAnalysis(props: Props) { <i className="icon-dropdown" /> </button> <ul className="dropdown-menu dropdown-menu-right"> - {version == null && + {props.version == null && <li> <AddEventForm addEvent={props.addVersion} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppContainer.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppContainer.js index 58b855b49f0..e7a4e2505ef 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppContainer.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppContainer.js @@ -258,7 +258,8 @@ class ProjectActivityAppContainer extends React.PureComponent { ); // if there is no filter, but there are saved preferences in the localStorage - return !filtered && getGraph(); + const graph = getGraph(); + return !filtered && graph != null && graph !== 'overview'; } }; 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 004ef709197..008cbc7869a 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 @@ -23,13 +23,7 @@ import { debounce, sortBy } from 'lodash'; import ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader'; import GraphsZoom from './GraphsZoom'; import StaticGraphs from './StaticGraphs'; -import { - GRAPHS_METRICS, - datesQueryChanged, - generateCoveredLinesMetric, - historyQueryChanged -} from '../utils'; -import { translate } from '../../../helpers/l10n'; +import { datesQueryChanged, generateSeries, historyQueryChanged } from '../utils'; import type { RawQuery } from '../../../helpers/query'; import type { Analysis, MeasureHistory, Query } from '../types'; import type { Serie } from '../../../components/charts/AdvancedTimeline'; @@ -57,7 +51,7 @@ export default class ProjectActivityGraphs extends React.PureComponent { constructor(props: Props) { super(props); - const series = this.getSeries(props.measuresHistory); + const series = generateSeries(props.measuresHistory, props.query.graph, props.metricsType); this.state = { graphStartDate: props.query.from || null, graphEndDate: props.query.to || null, @@ -71,8 +65,13 @@ export default class ProjectActivityGraphs extends React.PureComponent { nextProps.measuresHistory !== this.props.measuresHistory || historyQueryChanged(this.props.query, nextProps.query) ) { - const series = this.getSeries(nextProps.measuresHistory); - this.setState({ series }); + this.setState({ + series: generateSeries( + nextProps.measuresHistory, + nextProps.query.graph, + nextProps.metricsType + ) + }); } if ( nextProps.query !== this.props.query && @@ -82,26 +81,6 @@ export default class ProjectActivityGraphs extends React.PureComponent { } } - getSeries = (measuresHistory: Array<MeasureHistory>): Array<Serie> => - measuresHistory.map(measure => { - if (measure.metric === 'uncovered_lines') { - return generateCoveredLinesMetric( - measure, - measuresHistory, - GRAPHS_METRICS[this.props.query.graph].indexOf(measure.metric) - ); - } - return { - name: measure.metric, - translatedName: translate('metric', measure.metric, 'name'), - style: GRAPHS_METRICS[this.props.query.graph].indexOf(measure.metric), - data: measure.history.map(analysis => ({ - x: analysis.date, - y: this.props.metricsType === 'LEVEL' ? analysis.value : Number(analysis.value) - })) - }; - }); - updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) => { if (graphEndDate != null && graphStartDate != null) { const msDiff = Math.abs(graphEndDate.valueOf() - graphStartDate.valueOf()); 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 7428bdd3282..86379691768 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 @@ -47,8 +47,6 @@ export default class StaticGraphs extends React.PureComponent { formatYTick = tick => formatMeasure(tick, getShortType(this.props.metricsType)); - formatValue = value => formatMeasure(value, this.props.metricsType); - getEvents = () => { const { analyses, eventFilter } = this.props; const filteredEvents = analyses.reduce((acc, analysis) => { @@ -73,7 +71,7 @@ export default class StaticGraphs extends React.PureComponent { return sortBy(filteredEvents, 'date'); }; - hasHistoryData = () => some(this.props.series, serie => serie.data && serie.data.length > 2); + hasSeriesData = () => some(this.props.series, serie => serie.data && serie.data.length > 2); render() { const { loading } = this.props; @@ -88,7 +86,7 @@ export default class StaticGraphs extends React.PureComponent { ); } - if (!this.hasHistoryData()) { + if (!this.hasSeriesData()) { return ( <div className="project-activity-graph-container"> <div className="note text-center"> @@ -111,7 +109,6 @@ export default class StaticGraphs extends React.PureComponent { height={height} width={width} interpolate="linear" - formatValue={this.formatValue} formatYTick={this.formatYTick} leakPeriodDate={this.props.leakPeriodDate} metricType={this.props.metricsType} 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 70447874ed1..2c0c3c818bc 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/utils.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.js @@ -19,6 +19,7 @@ */ // @flow import moment from 'moment'; +import { sortBy } from 'lodash'; import { cleanQuery, parseAsDate, @@ -29,6 +30,7 @@ import { import { translate } from '../../helpers/l10n'; import type { Analysis, MeasureHistory, Query } from './types'; import type { RawQuery } from '../../helpers/query'; +import type { Serie } from '../../components/charts/AdvancedTimeline'; export const EVENT_TYPES = ['VERSION', 'QUALITY_GATE', 'QUALITY_PROFILE', 'OTHER']; export const GRAPH_TYPES = ['overview', 'coverage', 'duplications', 'remediation']; @@ -69,6 +71,30 @@ export const generateCoveredLinesMetric = ( }; }; +export const generateSeries = ( + measuresHistory: Array<MeasureHistory>, + graph: string, + dataType: string +): Array<Serie> => + measuresHistory.map(measure => { + if (measure.metric === 'uncovered_lines') { + return generateCoveredLinesMetric( + measure, + measuresHistory, + GRAPHS_METRICS[graph].indexOf(measure.metric) + ); + } + return { + name: measure.metric, + translatedName: translate('metric', measure.metric, 'name'), + style: GRAPHS_METRICS[graph].indexOf(measure.metric), + data: measure.history.map(analysis => ({ + x: analysis.date, + y: dataType === 'LEVEL' ? analysis.value : Number(analysis.value) + })) + }; + }); + export const getAnalysesByVersionByDay = ( analyses: Array<Analysis> ): Array<{ @@ -84,10 +110,18 @@ export const getAnalysesByVersionByDay = ( if (!currentVersion.byDay[day]) { currentVersion.byDay[day] = []; } - currentVersion.byDay[day].push(analysis); - const versionEvent = analysis.events.find(event => event.category === 'VERSION'); - if (versionEvent) { - currentVersion.version = versionEvent.name; + const sortedEvents = sortBy( + analysis.events, + // versions last + event => (event.category === 'VERSION' ? 1 : 0), + // then the rest sorted by category + 'category' + ); + currentVersion.byDay[day].push({ ...analysis, events: sortedEvents }); + + const lastEvent = sortedEvents[sortedEvents.length - 1]; + if (lastEvent && lastEvent.category === 'VERSION') { + currentVersion.version = lastEvent.name; acc.push({ version: undefined, byDay: {} }); } return acc; 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 fa495a1ae8c..41c90942099 100644 --- a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js @@ -35,17 +35,19 @@ type Props = { endDate: ?Date, events?: Array<Event>, eventSize?: number, - formatYTick: number => string, - formatValue: number => string, + disableZoom?: boolean, + formatYTick?: number => string, + hideGrid?: boolean, + hideXAxis?: boolean, height: number, width: number, - leakPeriodDate: Date, + leakPeriodDate?: Date, padding: Array<number>, series: Array<Serie>, showAreas?: boolean, showEventMarkers?: boolean, startDate: ?Date, - updateZoom: (start: ?Date, endDate: ?Date) => void, + updateZoom?: (start: ?Date, endDate: ?Date) => void, zoomSpeed: number }; @@ -112,10 +114,12 @@ export default class AdvancedTimeline extends React.PureComponent { const rightPos = xRange[1] + Math.round(speed * evt.deltaY * (1 - mouseXPos)); const startDate = leftPos > maxXRange[0] ? xScale.invert(leftPos) : null; const endDate = rightPos < maxXRange[1] ? xScale.invert(rightPos) : null; + // $FlowFixMe updateZoom can't be undefined at this point this.props.updateZoom(startDate, endDate); }; renderHorizontalGrid = (xScale: Scale, yScale: Scale) => { + const { formatYTick } = this.props; const hasTicks = typeof yScale.ticks === 'function'; const ticks = hasTicks ? yScale.ticks(4) : yScale.domain(); @@ -127,15 +131,16 @@ export default class AdvancedTimeline extends React.PureComponent { <g> {ticks.map(tick => ( <g key={tick}> - <text - className="line-chart-tick line-chart-tick-x" - dx="-1em" - dy="0.3em" - textAnchor="end" - x={xScale.range()[0]} - y={yScale(tick)}> - {this.props.formatYTick(tick)} - </text> + {formatYTick != null && + <text + className="line-chart-tick line-chart-tick-x" + dx="-1em" + dy="0.3em" + textAnchor="end" + x={xScale.range()[0]} + y={yScale(tick)}> + {formatYTick(tick)} + </text>} <line className="line-chart-grid" x1={xScale.range()[0]} @@ -149,7 +154,7 @@ export default class AdvancedTimeline extends React.PureComponent { ); }; - renderTicks = (xScale: Scale, yScale: Scale) => { + renderXAxisTicks = (xScale: Scale, yScale: Scale) => { const format = xScale.tickFormat(7); const ticks = xScale.ticks(7); const y = yScale.range()[0]; @@ -169,9 +174,6 @@ export default class AdvancedTimeline extends React.PureComponent { }; renderLeak = (xScale: Scale, yScale: Scale) => { - if (!this.props.leakPeriodDate) { - return null; - } const yRange = yScale.range(); const xRange = xScale.range(); const leakWidth = xRange[xRange.length - 1] - xScale(this.props.leakPeriodDate); @@ -282,20 +284,21 @@ export default class AdvancedTimeline extends React.PureComponent { } const { maxXRange, xScale, yScale } = this.getScales(); + const zoomEnabled = !this.props.disableZoom && this.props.updateZoom != null; const isZoomed = this.props.startDate || this.props.endDate; return ( <svg className={classNames('line-chart', { 'chart-zoomed': isZoomed })} width={this.props.width} height={this.props.height}> - {this.renderClipPath(xScale, yScale)} + {zoomEnabled && this.renderClipPath(xScale, yScale)} <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> - {this.renderLeak(xScale, yScale)} - {this.renderHorizontalGrid(xScale, yScale)} - {this.renderTicks(xScale, yScale)} + {this.props.leakPeriodDate != null && this.renderLeak(xScale, yScale)} + {!this.props.hideGrid && this.renderHorizontalGrid(xScale, yScale)} + {!this.props.hideXAxis && this.renderXAxisTicks(xScale, yScale)} {this.props.showAreas && this.renderAreas(xScale, yScale)} {this.renderLines(xScale, yScale)} - {this.renderZoomOverlay(xScale, yScale, maxXRange)} + {zoomEnabled && this.renderZoomOverlay(xScale, yScale, maxXRange)} {this.props.showEventMarkers && this.renderEvents(xScale, yScale)} </g> </svg> diff --git a/server/sonar-web/src/main/js/helpers/storage.js b/server/sonar-web/src/main/js/helpers/storage.js index ac6fa964f5f..fc7ec4d0ad5 100644 --- a/server/sonar-web/src/main/js/helpers/storage.js +++ b/server/sonar-web/src/main/js/helpers/storage.js @@ -64,4 +64,4 @@ export const saveSort = (sort: ?string) => save(PROJECTS_SORT, sort); export const getSort = () => window.localStorage.getItem(PROJECTS_SORT); export const saveGraph = (graph: ?string) => save(PROJECT_ACTIVITY_GRAPH, graph); -export const getGraph = () => window.localStorage.getItem(PROJECT_ACTIVITY_GRAPH); +export const getGraph = () => window.localStorage.getItem(PROJECT_ACTIVITY_GRAPH) || 'overview'; |