diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-06-13 17:41:46 +0200 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-07-04 14:15:34 +0200 |
commit | 5b03b1c707168fcd83a3eee3bae40ad0d2c7022f (patch) | |
tree | 06bf7daaa3f33083388a6c502380acc27bae963a | |
parent | 47b553e761f7e061cdce150003123f1f5de724be (diff) | |
download | sonarqube-5b03b1c707168fcd83a3eee3bae40ad0d2c7022f.tar.gz sonarqube-5b03b1c707168fcd83a3eee3bae40ad0d2c7022f.zip |
SONAR-9401 Add the overview chart to the project activity page
16 files changed, 911 insertions, 87 deletions
diff --git a/server/sonar-web/src/main/js/api/time-machine.js b/server/sonar-web/src/main/js/api/time-machine.js index 9104ec1d391..2378a726302 100644 --- a/server/sonar-web/src/main/js/api/time-machine.js +++ b/server/sonar-web/src/main/js/api/time-machine.js @@ -38,7 +38,7 @@ type Response = { export const getTimeMachineData = ( component: string, metrics: Array<string>, - other?: {} + other?: { p?: number, ps?: number, from?: string, to?: string } ): Promise<Response> => getJSON('/api/measures/search_history', { component, @@ -46,3 +46,39 @@ export const getTimeMachineData = ( ps: 1000, ...other }); + +export const getAllTimeMachineData = ( + component: string, + metrics: Array<string>, + other?: { p?: number, ps?: number, from?: string, to?: string }, + prev?: Response +): Promise<Response> => + getTimeMachineData(component, metrics, other).then((r: Response) => { + const result = prev + ? { + measures: prev.measures.map((measure, idx) => ({ + ...measure, + history: measure.history.concat(r.measures[idx].history) + })), + paging: r.paging + } + : r; + + if ( + // TODO Remove the sameAsPrevious condition when the webservice paging is working correctly ? + // Or keep it to be sure to not have an infinite loop ? + result.measures.every((measure, idx) => { + const equalToTotal = measure.history.length >= result.paging.total; + const sameAsPrevious = prev && measure.history.length === prev.measures[idx].history.length; + return equalToTotal || sameAsPrevious; + }) + ) { + return result; + } + return getAllTimeMachineData( + component, + metrics, + { ...other, p: result.paging.pageIndex + 1 }, + result + ); + }); 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 96ab8cb14ad..b74b7179090 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 @@ -22,9 +22,10 @@ import React from 'react'; import { groupBy } from 'lodash'; import moment from 'moment'; import ProjectActivityAnalysis from './ProjectActivityAnalysis'; +import ProjectActivityPageFooter from './ProjectActivityPageFooter'; import FormattedDate from '../../../components/ui/FormattedDate'; import { translate } from '../../../helpers/l10n'; -import type { Analysis } from '../types'; +import type { Analysis, Paging } from '../types'; type Props = { addCustomEvent: (analysis: string, name: string, category?: string) => Promise<*>, @@ -33,49 +34,63 @@ type Props = { canAdmin: boolean, changeEvent: (event: string, name: string) => Promise<*>, deleteAnalysis: (analysis: string) => Promise<*>, - deleteEvent: (analysis: string, event: string) => Promise<*> + deleteEvent: (analysis: string, event: string) => Promise<*>, + fetchMoreActivity: () => void, + paging?: Paging }; export default function ProjectActivityAnalysesList(props: Props) { if (props.analyses.length === 0) { - return <div className="note">{translate('no_results')}</div>; + return ( + <div className="layout-page-side-outer project-activity-page-side-outer"> + <div className="boxed-group boxed-group-inner"> + <div className="note">{translate('no_results')}</div> + </div> + </div> + ); } const firstAnalysis = props.analyses[0]; - const byDay = groupBy(props.analyses, analysis => moment(analysis.date).startOf('day').valueOf()); - return ( - <div className="boxed-group boxed-group-inner"> - <ul className="project-activity-days-list"> - {Object.keys(byDay).map(day => ( - <li - key={day} - className="project-activity-day" - data-day={moment(Number(day)).format('YYYY-MM-DD')}> - <div className="project-activity-date"> - <FormattedDate date={Number(day)} format="LL" /> - </div> + <div className="layout-page-side-outer project-activity-page-side-outer"> + <div className="boxed-group boxed-group-inner"> + <ul className="project-activity-days-list"> + {Object.keys(byDay).map(day => ( + <li + key={day} + className="project-activity-day" + data-day={moment(Number(day)).format('YYYY-MM-DD')}> + <div className="project-activity-date"> + <FormattedDate date={Number(day)} format="LL" /> + </div> + + <ul className="project-activity-analyses-list"> + {byDay[day] != null && + byDay[day].map(analysis => ( + <ProjectActivityAnalysis + addCustomEvent={props.addCustomEvent} + addVersion={props.addVersion} + analysis={analysis} + canAdmin={props.canAdmin} + changeEvent={props.changeEvent} + deleteAnalysis={props.deleteAnalysis} + deleteEvent={props.deleteEvent} + isFirst={analysis === firstAnalysis} + key={analysis.key} + /> + ))} + </ul> + </li> + ))} + </ul> - <ul className="project-activity-analyses-list"> - {byDay[day] != null && - byDay[day].map(analysis => ( - <ProjectActivityAnalysis - addCustomEvent={props.addCustomEvent} - addVersion={props.addVersion} - analysis={analysis} - canAdmin={props.canAdmin} - changeEvent={props.changeEvent} - deleteAnalysis={props.deleteAnalysis} - deleteEvent={props.deleteEvent} - isFirst={analysis === firstAnalysis} - key={analysis.key} - /> - ))} - </ul> - </li> - ))} - </ul> + <ProjectActivityPageFooter + analyses={props.analyses} + fetchMoreActivity={props.fetchMoreActivity} + paging={props.paging} + /> + </div> </div> ); } 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 7289766ece9..e23b67da52e 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 @@ -20,16 +20,19 @@ // @flow import React from 'react'; import Helmet from 'react-helmet'; +import moment from 'moment'; import ProjectActivityPageHeader from './ProjectActivityPageHeader'; import ProjectActivityAnalysesList from './ProjectActivityAnalysesList'; -import ProjectActivityPageFooter from './ProjectActivityPageFooter'; +import ProjectActivityGraphs from './ProjectActivityGraphs'; import throwGlobalError from '../../../app/utils/throwGlobalError'; import * as api from '../../../api/projectActivity'; import * as actions from '../actions'; -import { parseQuery, serializeQuery, serializeUrlQuery } from '../utils'; +import { getAllTimeMachineData } from '../../../api/time-machine'; +import { getMetrics } from '../../../api/metrics'; +import { GRAPHS_METRICS, parseQuery, serializeQuery, serializeUrlQuery } from '../utils'; import { translate } from '../../../helpers/l10n'; import './projectActivity.css'; -import type { Analysis, Query, Paging } from '../types'; +import type { Analysis, LeakPeriod, MeasureHistory, Metric, Query, Paging } from '../types'; import type { RawQuery } from '../../../helpers/query'; type Props = { @@ -40,7 +43,11 @@ type Props = { export type State = { analyses: Array<Analysis>, + leakPeriod?: LeakPeriod, loading: boolean, + measures: Array<*>, + metrics: Array<Metric>, + measuresHistory: Array<MeasureHistory>, paging?: Paging, query: Query }; @@ -52,7 +59,14 @@ export default class ProjectActivityApp extends React.PureComponent { constructor(props: Props) { super(props); - this.state = { analyses: [], loading: true, query: parseQuery(props.location.query) }; + this.state = { + analyses: [], + loading: true, + measures: [], + measuresHistory: [], + metrics: [], + query: parseQuery(props.location.query) + }; } componentDidMount() { @@ -85,6 +99,21 @@ export default class ProjectActivityApp extends React.PureComponent { return api.getProjectActivity(parameters).catch(throwGlobalError); }; + fetchMetrics = (): Promise<Array<Metric>> => getMetrics().catch(throwGlobalError); + + fetchMeasuresHistory = (metrics: Array<string>): Promise<Array<MeasureHistory>> => + 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); + fetchMoreActivity = () => { const { paging, query } = this.state; if (!paging) { @@ -136,15 +165,29 @@ export default class ProjectActivityApp extends React.PureComponent { .then(() => this.mounted && this.setState(actions.deleteAnalysis(analysis))) .catch(throwGlobalError); + getMetricType = () => { + const metricKey = GRAPHS_METRICS[this.state.query.graph][0]; + const metric = this.state.metrics.find(metric => metric.key === metricKey); + return metric ? metric.type : 'INT'; + }; + handleQueryChange() { const query = parseQuery(this.props.location.query); + const graphMetrics = GRAPHS_METRICS[query.graph]; this.setState({ loading: true, query }); - this.fetchActivity(query).then(({ analyses, paging }) => { + + Promise.all([ + this.fetchActivity(query), + this.fetchMetrics(), + this.fetchMeasuresHistory(graphMetrics) + ]).then(response => { if (this.mounted) { this.setState({ - analyses, + analyses: response[0].analyses, loading: false, - paging + metrics: response[1], + measuresHistory: response[2], + paging: response[0].paging }); } }); @@ -174,21 +217,30 @@ export default class ProjectActivityApp extends React.PureComponent { <ProjectActivityPageHeader category={query.category} updateQuery={this.updateQuery} /> - <ProjectActivityAnalysesList - addCustomEvent={this.addCustomEvent} - addVersion={this.addVersion} - analyses={this.state.analyses} - canAdmin={canAdmin} - changeEvent={this.changeEvent} - deleteAnalysis={this.deleteAnalysis} - deleteEvent={this.deleteEvent} - /> - - <ProjectActivityPageFooter - analyses={this.state.analyses} - fetchMoreActivity={this.fetchMoreActivity} - paging={this.state.paging} - /> + <div className="layout-page project-activity-page"> + <ProjectActivityAnalysesList + addCustomEvent={this.addCustomEvent} + addVersion={this.addVersion} + analyses={this.state.analyses} + canAdmin={canAdmin} + changeEvent={this.changeEvent} + deleteAnalysis={this.deleteAnalysis} + deleteEvent={this.deleteEvent} + fetchMoreActivity={this.fetchMoreActivity} + paging={this.state.paging} + /> + + <ProjectActivityGraphs + analyses={this.state.analyses} + leakPeriod={this.state.leakPeriod} + loading={this.state.loading} + measuresHistory={this.state.measuresHistory} + metricsType={this.getMetricType()} + project={this.props.project.key} + query={query} + updateQuery={this.updateQuery} + /> + </div> </div> ); } 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 new file mode 100644 index 00000000000..167567a8918 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js @@ -0,0 +1,52 @@ +/* + * 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 ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader'; +import StaticGraphs from './StaticGraphs'; +import type { RawQuery } from '../../../helpers/query'; +import type { Analysis, MeasureHistory, Query } from '../types'; + +type Props = { + analyses: Array<Analysis>, + loading: boolean, + measuresHistory: Array<MeasureHistory>, + metricsType: string, + project: string, + query: Query, + updateQuery: RawQuery => void +}; + +export default function ProjectActivityGraphs(props: Props) { + return ( + <div className="project-activity-layout-page-main"> + <div className="project-activity-layout-page-main-inner boxed-group boxed-group-inner"> + <ProjectActivityGraphsHeader graph={props.query.graph} updateQuery={props.updateQuery} /> + <StaticGraphs + analyses={props.analyses} + loading={props.loading} + measuresHistory={props.measuresHistory} + metricsType={props.metricsType} + project={props.project} + /> + </div> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js new file mode 100644 index 00000000000..33ee4ffab72 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js @@ -0,0 +1,60 @@ +/* + * 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 Select from 'react-select'; +import { GRAPH_TYPES } from '../utils'; +import { translate } from '../../../helpers/l10n'; +import type { RawQuery } from '../../../helpers/query'; + +type Props = { + updateQuery: RawQuery => void, + graph: string +}; + +export default class ProjectActivityGraphsHeader extends React.PureComponent { + props: Props; + + handleGraphChange = (option: { value: string }) => { + if (option.value !== this.props.graph) { + this.props.updateQuery({ graph: option.value }); + } + }; + + render() { + const selectOptions = GRAPH_TYPES.map(graph => ({ + label: translate('project_activity.graphs', graph), + value: graph + })); + + return ( + <header className="page-header"> + <Select + className="input-medium" + clearable={false} + searchable={false} + value={this.props.graph} + options={selectOptions} + onChange={this.handleGraphChange} + /> + </header> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js index 8dd16cb5045..29e3e16925f 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js @@ -43,21 +43,15 @@ export default class ProjectActivityPageHeader extends React.PureComponent { return ( <header className="page-header"> - <div className="page-actions"> - <Select - className="input-medium" - placeholder={translate('filter_verb') + '...'} - clearable={true} - searchable={false} - value={this.props.category} - options={selectOptions} - onChange={this.handleCategoryChange} - /> - </div> - - <div className="page-description"> - {translate('project_activity.page.description')} - </div> + <Select + className="input-medium" + placeholder={translate('project_activity.filter_events') + '...'} + clearable={true} + searchable={false} + value={this.props.category} + options={selectOptions} + onChange={this.handleCategoryChange} + /> </header> ); } 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 new file mode 100644 index 00000000000..1207d20439b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js @@ -0,0 +1,113 @@ +/* + * 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 moment from 'moment'; +import { some, sortBy } from 'lodash'; +import AdvancedTimeline from '../../../components/charts/AdvancedTimeline'; +import StaticGraphsLegend from './StaticGraphsLegend'; +import ResizeHelper from '../../../components/common/ResizeHelper'; +import { formatMeasure, getShortType } from '../../../helpers/measures'; +import { translate } from '../../../helpers/l10n'; +import type { Analysis, MeasureHistory } from '../types'; + +type Props = { + analyses: Array<Analysis>, + loading: boolean, + measuresHistory: Array<MeasureHistory>, + metricsType: string +}; + +export default class StaticGraphs extends React.PureComponent { + props: Props; + + getEvents = () => { + const events = this.props.analyses.reduce((acc, analysis) => { + return acc.concat( + analysis.events.map(event => ({ + className: event.category, + name: event.name, + date: moment(analysis.date).toDate() + })) + ); + }, []); + return sortBy(events, 'date'); + }; + + 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) + })) + })); + + hasHistoryData = () => + some(this.props.measuresHistory, measure => measure.history && measure.history.length > 2); + + render() { + const { loading } = this.props; + + if (loading) { + return ( + <div className="project-activity-graph-container"> + <div className="text-center"> + <i className="spinner" /> + </div> + </div> + ); + } + + if (!this.hasHistoryData()) { + return ( + <div className="project-activity-graph-container"> + <div className="note text-center"> + {translate('component_measures.no_history')} + </div> + </div> + ); + } + + const { metricsType } = this.props; + const formatValue = value => formatMeasure(value, metricsType); + const formatYTick = tick => formatMeasure(tick, getShortType(metricsType)); + const series = this.getSeries(); + return ( + <div className="project-activity-graph-container"> + <StaticGraphsLegend series={series} /> + <div className="project-activity-graph"> + <ResizeHelper> + <AdvancedTimeline + basisCurve={false} + series={series} + metricType={metricsType} + events={this.getEvents()} + interpolate="linear" + formatValue={formatValue} + formatYTick={formatYTick} + leakPeriodDate={this.props.leakPeriodDate} + padding={[25, 25, 30, 60]} + /> + </ResizeHelper> + </div> + </div> + ); + } +} 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 new file mode 100644 index 00000000000..9f76e8cde43 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js @@ -0,0 +1,42 @@ +/* + * 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 classNames from 'classnames'; +import ChartLegendIcon from '../../../components/icons-components/ChartLegendIcon'; +import { translate } from '../../../helpers/l10n'; + +type Props = { + series: Array<{ name: string }> +}; + +export default function StaticGraphsLegend({ series }: Props) { + return ( + <div className="project-activity-graph-legends"> + {series.map((serie, idx) => ( + <span className="big-spacer-left big-spacer-right" key={serie.name}> + <ChartLegendIcon + className={classNames('spacer-right line-chart-legend', 'line-chart-legend-' + idx)} + /> + {translate('metric', serie.name, 'name')} + </span> + ))} + </div> + ); +} 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 2b69f15e803..a9dc2009ddb 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 @@ -1,3 +1,58 @@ +.project-activity-page { + min-height: 600px; + height: calc(100vh - 250px); +} + +.project-activity-page-side-outer { + width: 400px; + overflow: auto; +} + +.project-activity-page-side-outer .boxed-group { + margin-bottom: 0; +} + +.project-activity-layout-page-main { + flex-grow: 1; + min-width: 640px; + padding-left: 20px; + display: flex; +} + +.project-activity-layout-page-main-inner { + min-width: 640px; + max-width: 880px; + margin-bottom: 0px; + flex: 1; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.project-activity-list { + max-width: 400px; +} + +.project-activity-graph-container { + padding: 10px 0; + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; +} + +.project-activity-graph { + flex: 1; + max-height: 500px; +} + +.project-activity-graph-legends { + flex-grow: 0; + padding-bottom: 16px; + text-align: center; +} + .project-activity-days-list {} .project-activity-day { diff --git a/server/sonar-web/src/main/js/apps/projectActivity/types.js b/server/sonar-web/src/main/js/apps/projectActivity/types.js index b3d8211dfc8..51cc48cbea4 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/types.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/types.js @@ -32,6 +32,23 @@ export type Analysis = { events: Array<Event> }; +export type LeakPeriod = { + date: string, + index: number, + mode: string, + parameter: string +}; + +export type HistoryItem = { date: Date, value: string }; + +export type MeasureHistory = { metric: string, history: Array<HistoryItem> }; + +export type Metric = { + key: string, + name: string, + type: string +}; + export type Paging = { pageIndex: number, pageSize: number, @@ -39,6 +56,7 @@ export type Paging = { }; export type Query = { - project: string, - category: string + category: string, + graph: string, + project: string }; 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 be1646db137..be198ce067d 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/utils.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.js @@ -22,19 +22,26 @@ import { cleanQuery, parseAsString, serializeString } from '../../helpers/query' import type { 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 parseQuery = (urlQuery: RawQuery): Query => ({ - project: parseAsString(urlQuery['id']), - category: parseAsString(urlQuery['category']) + category: parseAsString(urlQuery['category']), + graph: parseAsString(urlQuery['graph']) || 'overview', + project: parseAsString(urlQuery['id']) }); -export const serializeQuery = (query: Query): Query => +export const serializeQuery = (query: Query): RawQuery => cleanQuery({ - project: serializeString(query.project), - category: serializeString(query.category) + category: serializeString(query.category), + project: serializeString(query.project) }); -export const serializeUrlQuery = (query: Query): RawQuery => - cleanQuery({ - id: serializeString(query.project), - category: serializeString(query.category) +export const serializeUrlQuery = (query: Query): RawQuery => { + const graph = query.graph === 'overview' ? '' : query.graph; + return cleanQuery({ + category: serializeString(query.category), + graph: serializeString(graph), + id: serializeString(query.project) }); +}; diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js new file mode 100644 index 00000000000..a7cfa6b848c --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js @@ -0,0 +1,217 @@ +/* + * 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 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'; + +type Point = { x: Date, y: number | string }; + +type Serie = { name: string, data: Array<Point> }; + +type Event = { className?: string, name: string, date: Date }; + +type Scale = Function; + +type Props = { + basisCurve?: boolean, + events?: Array<Event>, + eventSize?: number, + formatYTick: number => string, + formatValue: number => string, + height: number, + width: number, + leakPeriodDate: Date, + padding: Array<number>, + series: Array<Serie> +}; + +export default class AdvancedTimeline extends React.PureComponent { + props: Props; + + static defaultProps = { + eventSize: 8, + padding: [10, 10, 10, 10] + }; + + getRatingScale = (availableHeight: number) => + scalePoint().domain([5, 4, 3, 2, 1]).range([availableHeight, 0]); + + getLevelScale = (availableHeight: number) => + scalePoint().domain(['ERROR', 'WARN', 'OK']).range([availableHeight, 0]); + + getYScale = (availableHeight: number, flatData: Array<Point>) => { + if (this.props.metricType === 'RATING') { + return this.getRatingScale(availableHeight); + } else if (this.props.metricType === 'LEVEL') { + return this.getLevelScale(availableHeight); + } else { + return scaleLinear().range([availableHeight, 0]).domain([0, max(flatData, d => d.y)]).nice(); + } + }; + + getXScale = (availableWidth: number, flatData: Array<Point>) => + scaleTime().domain(extent(flatData, d => d.x)).range([0, availableWidth]).clamp(true); + + getScales = () => { + const availableWidth = this.props.width - this.props.padding[1] - this.props.padding[3]; + const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2]; + const flatData = flatten(this.props.series.map((serie: Serie) => serie.data)); + return { + xScale: this.getXScale(availableWidth, flatData), + yScale: this.getYScale(availableHeight, flatData) + }; + }; + + getEventMarker = (size: number) => { + const half = size / 2; + return `M${half} 0 L${size} ${half} L ${half} ${size} L0 ${half} L${half} 0 L${size} ${half}`; + }; + + renderHorizontalGrid = (xScale: Scale, yScale: Scale) => { + const hasTicks = typeof yScale.ticks === 'function'; + const ticks = hasTicks ? yScale.ticks(4) : yScale.domain(); + + if (!ticks.length) { + ticks.push(yScale.domain()[1]); + } + + return ( + <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> + <line + className="line-chart-grid" + x1={xScale.range()[0]} + x2={xScale.range()[1]} + y1={yScale(tick)} + y2={yScale(tick)} + /> + </g> + ))} + </g> + ); + }; + + renderTicks = (xScale: Scale, yScale: Scale) => { + const format = xScale.tickFormat(7); + const ticks = xScale.ticks(7); + const y = yScale.range()[0]; + return ( + <g> + {ticks.slice(0, -1).map((tick, index) => { + const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; + const x = (xScale(tick) + xScale(nextTick)) / 2; + return ( + <text key={index} className="line-chart-tick" x={x} y={y} dy="2em"> + {format(tick)} + </text> + ); + })} + </g> + ); + }; + + renderLeak = (xScale: Scale, yScale: Scale) => { + if (!this.props.leakPeriodDate) { + return null; + } + const yScaleRange = yScale.range(); + return ( + <rect + x={xScale(this.props.leakPeriodDate)} + y={yScaleRange[yScaleRange.length - 1]} + width={xScale.range()[1] - xScale(this.props.leakPeriodDate)} + height={yScaleRange[0] - yScaleRange[yScaleRange.length - 1]} + fill="#fbf3d5" + /> + ); + }; + + renderLines = (xScale: Scale, yScale: Scale) => { + const line = d3Line().x(d => xScale(d.x)).y(d => yScale(d.y)); + if (this.props.basisCurve) { + line.curve(curveBasis); + } + return ( + <g> + {this.props.series.map((serie, idx) => ( + <path + key={`${idx}-${serie.name}`} + className={classNames('line-chart-path', 'line-chart-path-' + idx)} + d={line(serie.data)} + /> + ))} + </g> + ); + }; + + renderEvents = (xScale: Scale, yScale: Scale) => { + const { events, eventSize } = this.props; + if (!events || !eventSize) { + return null; + } + + const offset = eventSize / 2; + return ( + <g> + {events.map((event, idx) => ( + <path + d={this.getEventMarker(eventSize)} + className={classNames('line-chart-event', event.className)} + key={`${idx}-${event.date.getTime()}`} + transform={`translate(${xScale(event.date) - offset}, ${yScale.range()[0] - offset})`} + /> + ))} + </g> + ); + }; + + render() { + if (!this.props.width || !this.props.height) { + return <div />; + } + + const { xScale, yScale } = this.getScales(); + return ( + <svg className="line-chart" width={this.props.width} height={this.props.height}> + <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> + {this.renderLeak(xScale, yScale)} + {this.renderHorizontalGrid(xScale, yScale)} + {this.renderTicks(xScale, yScale)} + {this.renderLines(xScale, yScale)} + {this.renderEvents(xScale, yScale)} + </g> + </svg> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/common/ResizeHelper.js b/server/sonar-web/src/main/js/components/common/ResizeHelper.js new file mode 100644 index 00000000000..ec51c1c9c5e --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/ResizeHelper.js @@ -0,0 +1,75 @@ +/* + * 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/js/components/icons-components/ChartLegendIcon.js b/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js new file mode 100644 index 00000000000..76602133108 --- /dev/null +++ b/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js @@ -0,0 +1,40 @@ +/* + * 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'; + +type Props = { className?: string, size?: number }; + +export default function ChartLegendIcon({ className, size = 16 }: Props) { + /* eslint-disable max-len */ + return ( + <svg + className={className} + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + width={size} + height={size}> + <path + style={{ fill: 'currentColor' }} + d="M14.325 7.143v1.714q0 0.357-0.25 0.607t-0.607 0.25h-10.857q-0.357 0-0.607-0.25t-0.25-0.607v-1.714q0-0.357 0.25-0.607t0.607-0.25h10.857q0.357 0 0.607 0.25t0.25 0.607z" + /> + </svg> + ); +} diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less index 911c8d90738..3c411450c52 100644 --- a/server/sonar-web/src/main/less/components/graphics.less +++ b/server/sonar-web/src/main/less/components/graphics.less @@ -107,21 +107,67 @@ * Line Chart */ +@defaultSerieColor: @darkBlue; +@serieColor1: @blue; +@serieColor2: #26adff; + .line-chart { } .line-chart-path { fill: none; - stroke: @blue; + stroke: @defaultSerieColor; stroke-width: 2px; + + &.line-chart-path-1 { + stroke: @serieColor1 + } + + &.line-chart-path-2 { + stroke: @serieColor2; + } +} + +.line-chart-legend { + color: @defaultSerieColor; + + &.line-chart-legend-1 { + color: @serieColor1; + } + + &.line-chart-legend-2 { + color: @serieColor2; + } } .line-chart-point { fill: #fff; - stroke: @darkBlue; + stroke: @defaultSerieColor; stroke-width: 2px; } +.line-chart-event { + fill: #fff; + stroke: @defaultSerieColor; + stroke-width: 2px; + + &.VERSION { + stroke: @green; + } + + &.QUALITY_GATE { + stroke: @blue; + } + + &.QUALITY_PROFILE { + stroke: @orange; + } + + &.OTHER { + stroke: @purple; + } +} + .line-chart-backdrop { } 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 f2f47860d44..675ef106a6d 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -591,7 +591,6 @@ comparison.page=Compare view_projects.page=Projects portfolios.page=Portfolios project_activity.page=Activity -project_activity.page.description=The page shows the history of project analyses. #------------------------------------------------------------------------------ @@ -1271,8 +1270,8 @@ manual_rules.add_manual_rule=Add Manual Rule # #------------------------------------------------------------------------------ -project_activity.project_analyzed=Project Analyzed project_activity.add_version=Create Version +project_activity.project_analyzed=Project Analyzed project_activity.remove_version=Remove Version project_activity.remove_version.question=Are you sure you want to delete this version? project_activity.change_version=Change Version @@ -1282,6 +1281,9 @@ project_activity.remove_custom_event=Delete Event project_activity.remove_custom_event.question=Are you sure you want to delete this event? project_activity.delete_analysis=Delete Analysis project_activity.delete_analysis.question=Are you sure you want to delete this analysis from the project history? +project_activity.filter_events=Filter events + +project_activity.graphs.overview=Overview project_history.col.year=Year project_history.col.month=Month |