diff options
author | Stas Vilchik <vilchiks@gmail.com> | 2015-11-05 09:54:55 +0100 |
---|---|---|
committer | Stas Vilchik <vilchiks@gmail.com> | 2015-11-06 16:48:25 +0100 |
commit | bb00fc56370280793250bc06638f689295af2f01 (patch) | |
tree | df8cafb77a2032ab7c0063b289e2c9621636e303 /server | |
parent | 266e3139c3ef6112d43d3422489a0654937b1816 (diff) | |
download | sonarqube-bb00fc56370280793250bc06638f689295af2f01.tar.gz sonarqube-bb00fc56370280793250bc06638f689295af2f01.zip |
SONAR-6357 add detailed "Size" panel for the "Overview" main page
Diffstat (limited to 'server')
39 files changed, 1003 insertions, 431 deletions
diff --git a/server/sonar-web/src/main/js/apps/overview/app.js b/server/sonar-web/src/main/js/apps/overview/app.js index 418784e1405..48fae6531f7 100644 --- a/server/sonar-web/src/main/js/apps/overview/app.js +++ b/server/sonar-web/src/main/js/apps/overview/app.js @@ -13,6 +13,7 @@ class App { start (options) { let opts = _.extend({}, options, window.sonarqube.overview); _.extend(opts.component, options.component); + opts.urlRoot = window.baseUrl + '/overview'; $('html').toggleClass('dashboard-page', opts.component.hasSnapshot); let el = document.querySelector(opts.el); diff --git a/server/sonar-web/src/main/js/apps/overview/common-components.js b/server/sonar-web/src/main/js/apps/overview/common-components.js new file mode 100644 index 00000000000..993b98bc672 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/common-components.js @@ -0,0 +1,38 @@ +import React from 'react'; + +import { formatMeasure, formatMeasureVariation, localizeMetric } from '../../helpers/measures'; +import DrilldownLink from './helpers/drilldown-link'; +import { getShortType } from './helpers/metrics'; + + +export const DetailedMeasure = React.createClass({ + renderLeak () { + if (!this.props.leakPeriodDate) { + return null; + } + let leak = this.props.leak[this.props.metric]; + return <div className="overview-detailed-measure-leak"> + <span className="overview-detailed-measure-value"> + {formatMeasureVariation(leak, getShortType(this.props.type))} + </span> + </div>; + }, + + render () { + let measure = this.props.measures[this.props.metric]; + if (measure == null) { + return null; + } + + return <div className="overview-detailed-measure"> + <div className="overview-detailed-measure-nutshell"> + <span>{localizeMetric(this.props.metric)}</span> + <DrilldownLink component={this.props.component.key} metric={this.props.metric}> + <span className="overview-detailed-measure-value">{formatMeasure(measure, this.props.type)}</span> + </DrilldownLink> + {this.props.children} + </div> + {this.renderLeak()} + </div>; + } +}); diff --git a/server/sonar-web/src/main/js/apps/overview/domain/timeline.js b/server/sonar-web/src/main/js/apps/overview/domain/timeline.js index 88b95bcef34..9e5f5f8a47c 100644 --- a/server/sonar-web/src/main/js/apps/overview/domain/timeline.js +++ b/server/sonar-web/src/main/js/apps/overview/domain/timeline.js @@ -1,174 +1,7 @@ -import _ from 'underscore'; -import moment from 'moment'; -import React from 'react'; -import { LineChart } from '../../../components/charts/line-chart'; -import { getTimeMachineData } from '../../../api/time-machine'; -import { getEvents } from '../../../api/events'; -import { formatMeasure } from '../../../helpers/measures'; -const HEIGHT = 280; -function parseValue (value, type) { - return type === 'RATING' && typeof value === 'string' ? - value.charCodeAt(0) - 'A'.charCodeAt(0) + 1 : - value; -} -export class DomainTimeline extends React.Component { - constructor (props) { - super(props); - this.state = { loading: true, currentMetric: props.initialMetric }; - } - - componentDidMount () { - Promise.all([ - this.requestTimeMachineData(), - this.requestEvents() - ]).then(() => this.setState({ loading: false })); - } - - requestTimeMachineData () { - return getTimeMachineData(this.props.component.key, this.state.currentMetric).then(r => { - let snapshots = r[0].cells.map(cell => { - return { date: moment(cell.d).toDate(), value: cell.v[0] }; - }); - this.setState({ snapshots }); - }); - } - - requestEvents () { - return getEvents(this.props.component.key, 'Version').then(r => { - let events = r.map(event => { - return { version: event.n, date: moment(event.dt).toDate() }; - }); - events = _.sortBy(events, 'date'); - this.setState({ events }); - }); - } - - prepareEvents () { - let events = this.state.events; - let snapshots = this.state.snapshots; - return events - .map(event => { - let snapshot = snapshots.find(s => s.date.getTime() === event.date.getTime()); - event.value = snapshot && snapshot.value; - return event; - }) - .filter(event => event.value != null); - } - - handleMetricChange () { - let metric = this.refs.metricSelect.value; - this.setState({ currentMetric: metric }, this.requestTimeMachineData); - } - - groupMetricsByDomain () { - return _.sortBy( - _.map( - _.groupBy(this.props.metrics, 'domain'), - (metricList, domain) => { - return { - domain: domain, - metrics: _.sortBy(metricList, 'name') - }; - }), - 'domain' - ); - } - - renderLoading () { - return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> - <i className="spinner"/> - </div>; - } - - renderWhenNoHistoricalData () { - return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> - There is no historical data. - </div>; - } - - renderLineChart () { - if (this.state.loading) { - return this.renderLoading(); - } - - let events = this.prepareEvents(); - - if (!events.length) { - return this.renderWhenNoHistoricalData(); - } - - let currentMetricType = _.findWhere(this.props.metrics, { key: this.state.currentMetric }).type; - - let data = events.map((event, index) => { - return { x: index, y: parseValue(event.value, currentMetricType) }; - }); - - let xTicks = events.map(event => event.version.substr(0, 6)); - - let xValues = events.map(event => { - return currentMetricType === 'RATING' ? event.value : formatMeasure(event.value, currentMetricType); - }); - - // TODO use leak period - let backdropConstraints = [ - this.state.events.length - 2, - this.state.events.length - 1 - ]; - - return <LineChart data={data} - xTicks={xTicks} - xValues={xValues} - backdropConstraints={backdropConstraints} - height={HEIGHT} - interpolate="linear" - padding={[25, 30, 50, 30]}/>; - } - - renderMetricOption (metric) { - return <option key={metric.key} value={metric.key}>{metric.name}</option>; - } - - renderTimelineMetricSelect () { - if (this.state.loading) { - return null; - } - let groupedMetrics = this.groupMetricsByDomain(); - let inner; - if (groupedMetrics.length > 1) { - inner = groupedMetrics.map(metricGroup => { - let options = metricGroup.metrics.map(this.renderMetricOption); - return <optgroup key={metricGroup.domain} label={metricGroup.domain}>{options}</optgroup>; - }); - } else { - inner = groupedMetrics[0].metrics.map(this.renderMetricOption); - } - return <select ref="metricSelect" - className="overview-timeline-select" - onChange={this.handleMetricChange.bind(this)} - value={this.state.currentMetric}>{inner}</select>; - } - - render () { - return <div className="overview-timeline overview-domain-dark"> - <div className="overview-domain-header"> - <h2 className="overview-title">Project History</h2> - {this.renderTimelineMetricSelect()} - </div> - <div> - {this.renderLineChart()} - </div> - </div>; - } -} - -DomainTimeline.propTypes = { - metrics: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, - initialMetric: React.PropTypes.string.isRequired -}; diff --git a/server/sonar-web/src/main/js/apps/overview/domain/treemap.js b/server/sonar-web/src/main/js/apps/overview/domain/treemap.js index 2bc32e36b7e..e22345f78d5 100644 --- a/server/sonar-web/src/main/js/apps/overview/domain/treemap.js +++ b/server/sonar-web/src/main/js/apps/overview/domain/treemap.js @@ -5,7 +5,7 @@ import { Treemap } from '../../../components/charts/treemap'; import { getChildren } from '../../../api/components'; import { formatMeasure } from '../../../helpers/measures'; -const HEIGHT = 360; +const HEIGHT = 302; export class DomainTreemap extends React.Component { @@ -79,15 +79,15 @@ export class DomainTreemap extends React.Component { render () { let color = this.props.colorMetric ? <li>Color: {this.state.colorMetric.name}</li> : null; - return <div className="overview-domain-section overview-treemap"> + return <div className="overview-domain overview-domain-chart"> <div className="overview-domain-header"> - <h2 className="overview-title">Project Components</h2> + <h2 className="overview-title">Treemap</h2> <ul className="list-inline small"> <li>Size: {this.state.sizeMetric.name}</li> {color} </ul> </div> - <div> + <div className="overview-treemap"> {this.renderTreemap()} </div> </div>; diff --git a/server/sonar-web/src/main/js/apps/overview/helpers/metrics.js b/server/sonar-web/src/main/js/apps/overview/helpers/metrics.js index f556841abe3..4b289bc3611 100644 --- a/server/sonar-web/src/main/js/apps/overview/helpers/metrics.js +++ b/server/sonar-web/src/main/js/apps/overview/helpers/metrics.js @@ -14,12 +14,26 @@ function isNotDifferential (metric) { return metric.key.indexOf('new_') !== 0; } -export function filterMetricsForDomains (metrics, domains) { +export function filterMetrics (metrics) { return metrics.filter(metric => { - return hasRightDomain(metric, domains) && isNotHidden(metric) && hasSimpleType(metric) && isNotDifferential(metric); + return isNotHidden(metric) && hasSimpleType(metric) && isNotDifferential(metric); }); } +export function filterMetricsForDomains (metrics, domains) { + return filterMetrics(metrics).filter(metric => hasRightDomain(metric, domains)); +} + + +export function getShortType (type) { + if (type === 'INT') { + return 'SHORT_INT'; + } else if (type === 'WORK_DUR') { + return 'SHORT_WORK_DUR'; + } + return type; +} + export function getMetricName (metricKey) { return window.t('overview.metric', metricKey); diff --git a/server/sonar-web/src/main/js/apps/overview/general/components.js b/server/sonar-web/src/main/js/apps/overview/main/components.js index 441f6d12e01..6371375dc2e 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/components.js +++ b/server/sonar-web/src/main/js/apps/overview/main/components.js @@ -2,6 +2,7 @@ import moment from 'moment'; import React from 'react'; import { Timeline } from './timeline'; +import { navigate } from '../../../components/router/router'; export const Domain = React.createClass({ @@ -25,16 +26,23 @@ export const DomainLeakTitle = React.createClass({ } let momentDate = moment(this.props.date); let fromNow = momentDate.fromNow(); - let tooltip = 'Started ' + fromNow + ', ' + momentDate.format('LLL'); + let tooltip = 'Started ' + fromNow + ', ' + momentDate.format('LL'); return <span title={tooltip} data-toggle="tooltip">Water Leak: {this.props.label}</span>; } }); export const DomainHeader = React.createClass({ + handleClick(e) { + e.preventDefault(); + navigate(this.props.linkTo); + }, + render () { return <div className="overview-domain-header"> - <DomainTitle>{this.props.title}</DomainTitle> + <DomainTitle> + <a onClick={this.handleClick} href="#">{this.props.title}</a> + </DomainTitle> <DomainLeakTitle label={this.props.leakPeriodLabel} date={this.props.leakPeriodDate}/> </div>; } diff --git a/server/sonar-web/src/main/js/apps/overview/general/coverage.js b/server/sonar-web/src/main/js/apps/overview/main/coverage.js index 2af648184ac..2af648184ac 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/coverage.js +++ b/server/sonar-web/src/main/js/apps/overview/main/coverage.js diff --git a/server/sonar-web/src/main/js/apps/overview/general/duplications.js b/server/sonar-web/src/main/js/apps/overview/main/duplications.js index bd95a2d4a39..bd95a2d4a39 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/duplications.js +++ b/server/sonar-web/src/main/js/apps/overview/main/duplications.js diff --git a/server/sonar-web/src/main/js/apps/overview/gate/gate-condition.js b/server/sonar-web/src/main/js/apps/overview/main/gate/gate-condition.js index 5699a79aeeb..0819d6002c8 100644 --- a/server/sonar-web/src/main/js/apps/overview/gate/gate-condition.js +++ b/server/sonar-web/src/main/js/apps/overview/main/gate/gate-condition.js @@ -1,8 +1,8 @@ import React from 'react'; -import Measure from './../helpers/measure'; -import { getPeriodLabel, getPeriodDate } from './../helpers/period-label'; -import DrilldownLink from './../helpers/drilldown-link'; +import Measure from '../../helpers/measure'; +import { getPeriodLabel, getPeriodDate } from '../../helpers/period-label'; +import DrilldownLink from '../../helpers/drilldown-link'; export default React.createClass({ diff --git a/server/sonar-web/src/main/js/apps/overview/gate/gate-conditions.js b/server/sonar-web/src/main/js/apps/overview/main/gate/gate-conditions.js index adefecbded4..adefecbded4 100644 --- a/server/sonar-web/src/main/js/apps/overview/gate/gate-conditions.js +++ b/server/sonar-web/src/main/js/apps/overview/main/gate/gate-conditions.js diff --git a/server/sonar-web/src/main/js/apps/overview/gate/gate-empty.js b/server/sonar-web/src/main/js/apps/overview/main/gate/gate-empty.js index 61347185593..61347185593 100644 --- a/server/sonar-web/src/main/js/apps/overview/gate/gate-empty.js +++ b/server/sonar-web/src/main/js/apps/overview/main/gate/gate-empty.js diff --git a/server/sonar-web/src/main/js/apps/overview/gate/gate.js b/server/sonar-web/src/main/js/apps/overview/main/gate/gate.js index 076cbcd376a..076cbcd376a 100644 --- a/server/sonar-web/src/main/js/apps/overview/gate/gate.js +++ b/server/sonar-web/src/main/js/apps/overview/main/gate/gate.js diff --git a/server/sonar-web/src/main/js/apps/overview/general/issues.js b/server/sonar-web/src/main/js/apps/overview/main/issues.js index e34bb88b073..e34bb88b073 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/issues.js +++ b/server/sonar-web/src/main/js/apps/overview/main/issues.js diff --git a/server/sonar-web/src/main/js/apps/overview/general/main.js b/server/sonar-web/src/main/js/apps/overview/main/main.js index dd0d748788c..dd0d748788c 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/main.js +++ b/server/sonar-web/src/main/js/apps/overview/main/main.js diff --git a/server/sonar-web/src/main/js/apps/overview/general/size.js b/server/sonar-web/src/main/js/apps/overview/main/size.js index 8642b9918ae..e639a239e7e 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/size.js +++ b/server/sonar-web/src/main/js/apps/overview/main/size.js @@ -35,8 +35,7 @@ export const GeneralSize = React.createClass({ render () { return <Domain> - <DomainHeader title="Size" - leakPeriodLabel={this.props.leakPeriodLabel} leakPeriodDate={this.props.leakPeriodDate}/> + <DomainHeader {...this.props} title="Size" linkTo="/size"/> <DomainPanel domain="size"> <DomainNutshell> diff --git a/server/sonar-web/src/main/js/apps/overview/general/timeline.js b/server/sonar-web/src/main/js/apps/overview/main/timeline.js index 3938fff0330..3938fff0330 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/timeline.js +++ b/server/sonar-web/src/main/js/apps/overview/main/timeline.js diff --git a/server/sonar-web/src/main/js/apps/overview/overview.js b/server/sonar-web/src/main/js/apps/overview/overview.js index f03573a2677..dae5318a477 100644 --- a/server/sonar-web/src/main/js/apps/overview/overview.js +++ b/server/sonar-web/src/main/js/apps/overview/overview.js @@ -1,12 +1,17 @@ import React from 'react'; -import Gate from './gate/gate'; -import GeneralMain from './general/main'; +import Gate from './main/gate/gate'; +import GeneralMain from './main/main'; import Meta from './meta'; +import { SizeMain } from './size/main'; + import { getMetrics } from '../../api/metrics'; +import { RouterMixin } from '../../components/router/router'; export const Overview = React.createClass({ + mixins: [RouterMixin], + getInitialState () { return { ready: false }; }, @@ -25,18 +30,34 @@ export const Overview = React.createClass({ </div>; }, - render () { - if (!this.state.ready) { - return this.renderLoading(); - } - + renderMain() { return <div className="overview"> <div className="overview-main"> <Gate component={this.props.component} gate={this.props.gate}/> - <GeneralMain {...this.props} {...this.state}/> + <GeneralMain {...this.props} {...this.state} navigate={this.navigate}/> </div> <Meta component={this.props.component}/> </div>; + }, + + renderSize () { + return <div className="overview"> + <SizeMain {...this.props} {...this.state}/> + </div>; + }, + + render () { + if (!this.state.ready) { + return this.renderLoading(); + } + switch (this.state.route) { + case '': + return this.renderMain(); + case '/size': + return this.renderSize(); + default: + throw new Error('Unknown route: ' + this.state.route); + } } }); diff --git a/server/sonar-web/src/main/js/apps/overview/size/comments-details.js b/server/sonar-web/src/main/js/apps/overview/size/comments-details.js deleted file mode 100644 index 1f2058276c8..00000000000 --- a/server/sonar-web/src/main/js/apps/overview/size/comments-details.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { DomainMeasuresList } from '../domain/measures-list'; - - -const METRICS = [ - 'comment_lines', - 'comment_lines_density' -]; - - -export class CommentsDetails extends React.Component { - render () { - return <div className="overview-domain-section"> - <h2 className="overview-title">Comments</h2> - <DomainMeasuresList {...this.props} metricsToDisplay={METRICS}/> - </div>; - } -} diff --git a/server/sonar-web/src/main/js/apps/overview/size/complexity-details.js b/server/sonar-web/src/main/js/apps/overview/size/complexity-details.js deleted file mode 100644 index bd1ba93b2ae..00000000000 --- a/server/sonar-web/src/main/js/apps/overview/size/complexity-details.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -import { DomainMeasuresList } from '../domain/measures-list'; - - -const METRICS = [ - 'complexity', - 'class_complexity', - 'file_complexity', - 'function_complexity' -]; - - -export class ComplexityDetails extends React.Component { - render () { - return <div className="overview-domain-section"> - <h2 className="overview-title">Complexity</h2> - <DomainMeasuresList {...this.props} metricsToDisplay={METRICS}/> - </div>; - } -} diff --git a/server/sonar-web/src/main/js/apps/overview/size/complexity-distribution.js b/server/sonar-web/src/main/js/apps/overview/size/complexity-distribution.js index 9582e985f3c..2c82df4d6a3 100644 --- a/server/sonar-web/src/main/js/apps/overview/size/complexity-distribution.js +++ b/server/sonar-web/src/main/js/apps/overview/size/complexity-distribution.js @@ -1,42 +1,15 @@ import React from 'react'; import { BarChart } from '../../../components/charts/bar-chart'; -import { getMeasures } from '../../../api/measures'; import { formatMeasure } from '../../../helpers/measures'; -const HEIGHT = 120; -const COMPLEXITY_DISTRIBUTION_METRIC = 'file_complexity_distribution'; +const HEIGHT = 80; export class ComplexityDistribution extends React.Component { - constructor (props) { - super(props); - this.state = { loading: true }; - } - - componentDidMount () { - this.requestData(); - } - - requestData () { - return getMeasures(this.props.component.key, [COMPLEXITY_DISTRIBUTION_METRIC]).then(measures => { - this.setState({ loading: false, distribution: measures[COMPLEXITY_DISTRIBUTION_METRIC] }); - }); - } - - renderLoading () { - return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> - <i className="spinner"/> - </div>; - } - renderBarChart () { - if (this.state.loading) { - return this.renderLoading(); - } - - let data = this.state.distribution.split(';').map((point, index) => { + let data = this.props.distribution.split(';').map((point, index) => { let tokens = point.split('='); return { x: index, y: parseInt(tokens[1], 10), value: parseInt(tokens[0], 10) }; }); @@ -48,24 +21,14 @@ export class ComplexityDistribution extends React.Component { return <BarChart data={data} xTicks={xTicks} xValues={xValues} - width={40 * data.length * 2 + 60} height={HEIGHT} - barsWidth={40} - padding={[25, 30, 50, 30]}/>; + barsWidth={10} + padding={[25, 0, 25, 0]}/>; } render () { return <div className="overview-bar-chart"> - <div className="overview-domain-header"> - <h2 className="overview-title"> </h2> - <ul className="list-inline small"> - <li>X: Complexity/file</li> - <li>Size: Number of Files</li> - </ul> - </div> - <div> - {this.renderBarChart()} - </div> + {this.renderBarChart()} </div>; } } diff --git a/server/sonar-web/src/main/js/apps/overview/size/language-distribution.js b/server/sonar-web/src/main/js/apps/overview/size/language-distribution.js index ab71de614fc..d3bde4d64ae 100644 --- a/server/sonar-web/src/main/js/apps/overview/size/language-distribution.js +++ b/server/sonar-web/src/main/js/apps/overview/size/language-distribution.js @@ -1,84 +1,52 @@ import _ from 'underscore'; import React from 'react'; -import { BarChart } from '../../../components/charts/bar-chart'; -import { getMeasures } from '../../../api/measures'; -import { getLanguages } from '../../../api/languages'; +import { Histogram } from '../../../components/charts/histogram'; import { formatMeasure } from '../../../helpers/measures'; - - -const HEIGHT = 180; -const COMPLEXITY_DISTRIBUTION_METRIC = 'ncloc_language_distribution'; +import { getLanguages } from '../../../api/languages'; export class LanguageDistribution extends React.Component { - constructor (props) { - super(props); - this.state = { loading: true }; - } - componentDidMount () { - this.requestData(); + this.requestLanguages(); } - requestData () { - return Promise.all([ - getMeasures(this.props.component.key, [COMPLEXITY_DISTRIBUTION_METRIC]), - getLanguages() - ]).then(responses => { - this.setState({ - loading: false, - distribution: responses[0][COMPLEXITY_DISTRIBUTION_METRIC], - languages: responses[1] - }); - }); + requestLanguages () { + getLanguages().then(languages => this.setState({ languages })); } getLanguageName (langKey) { - let lang = _.findWhere(this.state.languages, { key: langKey }); - return lang ? lang.name : window.t('unknown'); - } - - renderLoading () { - return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> - <i className="spinner"/> - </div>; + if (this.state && this.state.languages) { + let lang = _.findWhere(this.state.languages, { key: langKey }); + return lang ? lang.name : window.t('unknown'); + } else { + return langKey; + } } renderBarChart () { - if (this.state.loading) { - return this.renderLoading(); - } - - let data = this.state.distribution.split(';').map((d, index) => { - let tokens = d.split('='); - return { x: index, y: parseInt(tokens[1], 10), lang: tokens[0] }; + let data = this.props.distribution.split(';').map((point, index) => { + let tokens = point.split('='); + return { x: parseInt(tokens[1], 10), y: index, value: tokens[0] }; }); - let xTicks = data.map(d => this.getLanguageName(d.lang)); + data = _.sortBy(data, d => -d.x); + + let yTicks = data.map(point => this.getLanguageName(point.value)); - let xValues = data.map(d => formatMeasure(d.y, 'INT')); + let yValues = data.map(point => formatMeasure(point.x / this.props.lines * 100, 'PERCENT')); - return <BarChart data={data} - xTicks={xTicks} - xValues={xValues} - width={40 * data.length * 2 + 60} - height={HEIGHT} - barsWidth={40} - padding={[25, 30, 50, 30]}/>; + return <Histogram data={data} + yTicks={yTicks} + yValues={yValues} + height={data.length * 25} + barsWidth={10} + padding={[0, 50, 0, 80]}/>; } render () { return <div className="overview-bar-chart"> - <div className="overview-domain-header"> - <h2 className="overview-title"> </h2> - <ul className="list-inline small"> - <li>Size: Lines of Code</li> - </ul> - </div> - <div> - {this.renderBarChart()} - </div> + {this.renderBarChart()} </div>; } } diff --git a/server/sonar-web/src/main/js/apps/overview/size/main.js b/server/sonar-web/src/main/js/apps/overview/size/main.js index 9e037e136c9..a140c986873 100644 --- a/server/sonar-web/src/main/js/apps/overview/size/main.js +++ b/server/sonar-web/src/main/js/apps/overview/size/main.js @@ -1,52 +1,154 @@ import React from 'react'; -import { SizeTimeline } from './timeline'; -import { SizeDetails } from './size-details'; -import { ComplexityDetails } from './complexity-details'; -import { CommentsDetails } from './comments-details'; -import { ComplexityDistribution } from './complexity-distribution'; +import { DomainLeakTitle } from '../main/components'; import { LanguageDistribution } from './language-distribution'; -import { SizeTreemap } from './treemap'; +import { ComplexityDistribution } from './complexity-distribution'; +import { getMeasuresAndVariations } from '../../../api/measures'; +import { DetailedMeasure } from '../common-components'; +import { DomainTimeline } from '../timeline/domain-timeline'; +import { DomainTreemap } from '../domain/treemap'; +import { getPeriodLabel, getPeriodDate } from './../helpers/period-label'; +import { TooltipsMixin } from '../../../components/mixins/tooltips-mixin'; +import { filterMetrics, filterMetricsForDomains } from '../helpers/metrics'; -export default class extends React.Component { - render () { - return <div className="overview-detailed-page"> - <div className="overview-domain-header"> - <h2 className="overview-title">Size</h2> - </div> +export const SizeMain = React.createClass({ + mixins: [TooltipsMixin], - <a className="overview-detailed-page-back" href="#"> - <i className="icon-chevron-left"/> - </a> + getInitialState() { + return { + ready: false, + leakPeriodLabel: getPeriodLabel(this.props.component.periods, this.props.leakPeriodIndex), + leakPeriodDate: getPeriodDate(this.props.component.periods, this.props.leakPeriodIndex) + }; + }, - <SizeTimeline {...this.props}/> + componentDidMount() { + this.requestMeasures().then(r => { + let measures = this.getMeasuresValues(r, 'value'); + let leak = this.getMeasuresValues(r, 'var' + this.props.leakPeriodIndex); + this.setState({ ready: true, measures, leak }); + }); + }, - <div className="flex-columns"> - <div className="flex-column flex-column-third"> - <SizeDetails {...this.props}/> - </div> - <div className="flex-column flex-column-two-thirds"> - <LanguageDistribution {...this.props}/> - </div> - </div> + getMeasuresValues (measures, fieldKey) { + let values = {}; + Object.keys(measures).forEach(measureKey => { + values[measureKey] = measures[measureKey][fieldKey]; + }); + return values; + }, - <div className="flex-columns"> - <div className="flex-column flex-column-third"> - <ComplexityDetails {...this.props}/> - </div> - <div className="flex-column flex-column-two-thirds"> - <ComplexityDistribution {...this.props}/> + getMetricsForDomain() { + return this.props.metrics + .filter(metric => ['Size', 'Complexity', 'Documentation'].indexOf(metric.domain) !== -1) + .map(metric => metric.key); + }, + + getMetricsForTimeline() { + return filterMetricsForDomains(this.props.metrics, ['Size', 'Complexity', 'Documentation']); + }, + + getAllMetricsForTimeline() { + return filterMetrics(this.props.metrics); + }, + + requestMeasures () { + return getMeasuresAndVariations(this.props.component.key, this.getMetricsForDomain()); + }, + + renderLoading () { + return <div className="text-center"> + <i className="spinner spinner-margin"/> + </div>; + }, + + renderLegend () { + if (!this.state.leakPeriodDate) { + return null; + } + return <ul className="overview-legend list-inline"> + <li><span className="overview-legend-nutshell"/> Nutshell</li> + <li><span className="overview-legend-leak"/> <DomainLeakTitle label={this.state.leakPeriodLabel} + date={this.state.leakPeriodDate}/></li> + </ul>; + }, + + renderOtherMeasures(domain, hiddenMetrics) { + let metrics = filterMetricsForDomains(this.props.metrics, [domain]) + .filter(metric => hiddenMetrics.indexOf(metric.key) === -1) + .map(metric => { + return <DetailedMeasure key={metric.key} {...this.props} {...this.state} metric={metric.key} + type={metric.type}/>; + }); + return <div>{metrics}</div>; + }, + + renderOtherSizeMeasures() { + return this.renderOtherMeasures('Size', ['ncloc']); + }, + + renderOtherComplexityMeasures() { + return this.renderOtherMeasures('Complexity', + ['complexity', 'function_complexity', 'file_complexity', 'class_complexity']); + }, + + renderOtherDocumentationMeasures() { + return this.renderOtherMeasures('Documentation', []); + }, + + render () { + if (!this.state.ready) { + return this.renderLoading(); + } + return <div className="overview-detailed-page"> + <div className="overview-domain"> + <div className="overview-domain-header"> + <div className="overview-title">Size Overview</div> + {this.renderLegend()} </div> - </div> - <div className="flex-columns"> - <div className="flex-column flex-column-third"> - <CommentsDetails {...this.props}/> + <div className="overview-detailed-layout-size"> + <div className="overview-detailed-layout-column"> + <div className="overview-detailed-measures-list"> + <DetailedMeasure {...this.props} {...this.state} metric="ncloc" type="INT"> + <LanguageDistribution lines={this.state.measures['ncloc']} + distribution={this.state.measures['ncloc_language_distribution']}/> + </DetailedMeasure> + {this.renderOtherSizeMeasures()} + </div> + </div> + + <div className="overview-detailed-layout-column"> + <div className="overview-detailed-measures-list"> + <DetailedMeasure {...this.props} {...this.state} metric="complexity" type="INT"/> + <DetailedMeasure {...this.props} {...this.state} metric="function_complexity" type="FLOAT"> + <ComplexityDistribution distribution={this.state.measures['function_complexity_distribution']}/> + </DetailedMeasure> + <DetailedMeasure {...this.props} {...this.state} metric="file_complexity" type="FLOAT"> + <ComplexityDistribution distribution={this.state.measures['file_complexity_distribution']}/> + </DetailedMeasure> + <DetailedMeasure {...this.props} {...this.state} metric="class_complexity" type="FLOAT"/> + {this.renderOtherComplexityMeasures()} + </div> + </div> + + <div className="overview-detailed-layout-column"> + <div className="overview-detailed-measures-list"> + {this.renderOtherDocumentationMeasures()} + </div> + </div> </div> </div> - <SizeTreemap {...this.props}/> + <div className="overview-domain-charts"> + <DomainTimeline {...this.props} {...this.state} + initialMetric="ncloc" + metrics={this.getMetricsForTimeline()} + allMetrics={this.getAllMetricsForTimeline()}/> + <DomainTreemap {...this.props} sizeMetric="ncloc"/> + </div> </div>; + } -} +}); diff --git a/server/sonar-web/src/main/js/apps/overview/size/size-details.js b/server/sonar-web/src/main/js/apps/overview/size/size-details.js deleted file mode 100644 index c7362244222..00000000000 --- a/server/sonar-web/src/main/js/apps/overview/size/size-details.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import { DomainMeasuresList } from '../domain/measures-list'; - - -const METRICS = [ - 'ncloc', - 'lines', - 'files', - 'directories', - 'functions', - 'classes', - 'accessors' -]; - - -export class SizeDetails extends React.Component { - render () { - return <div className="overview-domain-section"> - <h2 className="overview-title">Size</h2> - <DomainMeasuresList {...this.props} metricsToDisplay={METRICS}/> - </div>; - } -} diff --git a/server/sonar-web/src/main/js/apps/overview/size/timeline.js b/server/sonar-web/src/main/js/apps/overview/size/timeline.js deleted file mode 100644 index 21978726ccf..00000000000 --- a/server/sonar-web/src/main/js/apps/overview/size/timeline.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -import { DomainTimeline } from '../domain/timeline'; -import { filterMetricsForDomains } from '../helpers/metrics'; - - -const DOMAINS = ['Size', 'Complexity', 'Documentation']; - - -export class SizeTimeline extends React.Component { - render () { - return <DomainTimeline {...this.props} - initialMetric="ncloc" - metrics={filterMetricsForDomains(this.props.metrics, DOMAINS)}/>; - } -} diff --git a/server/sonar-web/src/main/js/apps/overview/size/treemap.js b/server/sonar-web/src/main/js/apps/overview/size/treemap.js deleted file mode 100644 index ddfc2671db6..00000000000 --- a/server/sonar-web/src/main/js/apps/overview/size/treemap.js +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -import { DomainTreemap } from '../domain/treemap'; - - -export class SizeTreemap extends React.Component { - render () { - return <DomainTreemap {...this.props} sizeMetric="ncloc"/>; - } -} diff --git a/server/sonar-web/src/main/js/apps/overview/timeline/domain-timeline.js b/server/sonar-web/src/main/js/apps/overview/timeline/domain-timeline.js new file mode 100644 index 00000000000..59e5280bd48 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/timeline/domain-timeline.js @@ -0,0 +1,201 @@ +import _ from 'underscore'; +import moment from 'moment'; +import React from 'react'; + +import { getTimeMachineData } from '../../../api/time-machine'; +import { getEvents } from '../../../api/events'; +import { formatMeasure, groupByDomain } from '../../../helpers/measures'; +import { getShortType } from '../helpers/metrics'; +import { Timeline } from './timeline-chart'; + + +const HEIGHT = 280; + +function parseValue (value, type) { + return type === 'RATING' && typeof value === 'string' ? value.charCodeAt(0) - 'A'.charCodeAt(0) + 1 : value; +} + + +export const DomainTimeline = React.createClass({ + propTypes: { + allMetrics: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + metrics: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + initialMetric: React.PropTypes.string.isRequired + }, + + getInitialState() { + return { + loading: true, + currentMetric: this.props.initialMetric, + comparisonMetric: '' + }; + }, + + componentDidMount () { + Promise.all([ + this.requestTimeMachineData(this.state.currentMetric, this.state.comparisonMetric), + this.requestEvents() + ]).then(responses => { + this.setState({ + loading: false, + snapshots: responses[0], + events: responses[1] + }); + }); + }, + + requestTimeMachineData (currentMetric, comparisonMetric) { + let metricsToRequest = [currentMetric]; + if (comparisonMetric) { + metricsToRequest.push(comparisonMetric); + } + return getTimeMachineData(this.props.component.key, metricsToRequest.join()).then(r => { + return r[0].cells.map(cell => { + return { date: moment(cell.d).toDate(), values: cell.v }; + }); + }); + }, + + requestEvents () { + return getEvents(this.props.component.key, 'Version').then(r => { + let events = r.map(event => { + return { version: event.n, date: moment(event.dt).toDate() }; + }); + return _.sortBy(events, 'date'); + }); + }, + + handleMetricChange (e) { + let newMetric = e.target.value, + comparisonMetric = this.state.comparisonMetric; + if (newMetric === comparisonMetric) { + comparisonMetric = ''; + } + this.requestTimeMachineData(newMetric, comparisonMetric).then(snapshots => { + this.setState({ currentMetric: newMetric, comparisonMetric: comparisonMetric, snapshots }); + }); + }, + + handleComparisonMetricChange (e) { + let newMetric = e.target.value; + this.requestTimeMachineData(this.state.currentMetric, newMetric).then(snapshots => { + this.setState({ comparisonMetric: newMetric, snapshots }); + }); + }, + + groupMetricsByDomain () { + return groupByDomain(this.props.metrics); + }, + + renderLoading () { + return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> + <i className="spinner"/> + </div>; + }, + + renderWhenNoHistoricalData () { + return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> + There is no historical data. + </div>; + }, + + renderLineCharts () { + if (this.state.loading) { + return this.renderLoading(); + } + return <div> + {this.renderLineChart(this.state.snapshots, this.state.currentMetric, 0)} + {this.renderLineChart(this.state.snapshots, this.state.comparisonMetric, 1)} + </div>; + }, + + renderLineChart (snapshots, metric, index) { + if (!metric) { + return null; + } + + if (snapshots.length < 2) { + return this.renderWhenNoHistoricalData(); + } + + let metricType = _.findWhere(this.props.allMetrics, { key: metric }).type; + let data = snapshots.map(snapshot => { + return { + x: snapshot.date, + y: parseValue(snapshot.values[index], metricType) + }; + }); + + let formatValue = (value) => formatMeasure(value, metricType); + let formatYTick = (tick) => formatMeasure(tick, getShortType(metricType)); + + return <div className={'overview-timeline-' + index}> + <Timeline key={metric} + data={data} + events={this.state.events} + height={HEIGHT} + interpolate="linear" + formatValue={formatValue} + formatYTick={formatYTick} + leakPeriodDate={this.props.leakPeriodDate} + padding={[25, 25, 25, 60]}/> + </div>; + }, + + renderMetricOption (metric) { + return <option key={metric.key} value={metric.key}>{metric.name}</option>; + }, + + renderMetricOptions (metrics) { + let groupedMetrics = groupByDomain(metrics); + return groupedMetrics.map(metricGroup => { + let options = metricGroup.metrics.map(this.renderMetricOption); + return <optgroup key={metricGroup.domain} label={metricGroup.domain}>{options}</optgroup>; + }); + }, + + renderTimelineMetricSelect () { + if (this.state.loading) { + return null; + } + return <span> + <span className="overview-timeline-sample overview-timeline-sample-0"/> + <select ref="metricSelect" + className="overview-timeline-select" + onChange={this.handleMetricChange} + value={this.state.currentMetric}>{this.renderMetricOptions(this.props.metrics)}</select> + </span>; + }, + + renderComparisonMetricSelect () { + if (this.state.loading) { + return null; + } + let metrics = this.props.allMetrics.filter(metric => metric.key !== this.state.currentMetric); + return <span> + {this.state.comparisonMetric ? <span className="overview-timeline-sample overview-timeline-sample-1"/> : null} + <select ref="comparisonMetricSelect" + className="overview-timeline-select" + onChange={this.handleComparisonMetricChange} + value={this.state.comparisonMetric}> + <option value="">Compare with...</option> + {this.renderMetricOptions(metrics)} + </select> + </span>; + }, + + render () { + return <div className="overview-domain overview-domain-chart"> + <div className="overview-domain-header"> + <div> + <h2 className="overview-title">Timeline</h2> + {this.renderTimelineMetricSelect()} + </div> + {this.renderComparisonMetricSelect()} + </div> + <div className="overview-timeline"> + {this.renderLineCharts()} + </div> + </div>; + } +}); diff --git a/server/sonar-web/src/main/js/apps/overview/timeline/timeline-chart.js b/server/sonar-web/src/main/js/apps/overview/timeline/timeline-chart.js new file mode 100644 index 00000000000..fc61b1912ff --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/timeline/timeline-chart.js @@ -0,0 +1,132 @@ +import _ from 'underscore'; +import moment from 'moment'; +import React from 'react'; + +import { ResizeMixin } from '../../../components/mixins/resize-mixin'; +import { TooltipsMixin } from '../../../components/mixins/tooltips-mixin'; + + +export const Timeline = React.createClass({ + mixins: [ResizeMixin, TooltipsMixin], + + propTypes: { + data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + padding: React.PropTypes.arrayOf(React.PropTypes.number), + height: React.PropTypes.number, + interpolate: React.PropTypes.string + }, + + getDefaultProps() { + return { + padding: [10, 10, 10, 10], + interpolate: 'basis' + }; + }, + + getInitialState() { + return { width: this.props.width, height: this.props.height }; + }, + + renderHorizontalGrid (xScale, yScale) { + let ticks = yScale.ticks(4); + let grid = ticks.map(tick => { + let opts = { + x: xScale.range()[0], + y: yScale(tick) + }; + return <g key={tick}> + <text className="line-chart-tick line-chart-tick-x" dx="-1em" dy="0.3em" + textAnchor="end" {...opts}>{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>; + }); + return <g>{grid}</g>; + }, + + renderTicks (xScale, yScale) { + let format = xScale.tickFormat(7); + let ticks = xScale.ticks(7); + ticks = _.initial(ticks).map((tick, index) => { + let nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; + let x = (xScale(tick) + xScale(nextTick)) / 2; + let y = yScale.range()[0]; + return <text key={index} className="line-chart-tick" x={x} y={y} dy="1.5em">{format(tick)}</text>; + }); + return <g>{ticks}</g>; + }, + + renderLeak (xScale, yScale) { + if (!this.props.leakPeriodDate) { + return null; + } + let opts = { + x: xScale(this.props.leakPeriodDate), + y: yScale.range()[1], + width: xScale.range()[1] - xScale(this.props.leakPeriodDate), + height: yScale.range()[0] - yScale.range()[1], + fill: '#fffae7' + }; + return <rect {...opts}/>; + }, + + renderLine (xScale, yScale) { + let path = d3.svg.line() + .x(d => xScale(d.x)) + .y(d => yScale(d.y)) + .interpolate(this.props.interpolate); + return <path className="line-chart-path" d={path(this.props.data)}/>; + }, + + renderEvents(xScale, yScale) { + let points = this.props.events + .map(event => { + let snapshot = this.props.data.find(d => d.x.getTime() === event.date.getTime()); + return _.extend(event, { snapshot }); + }) + .filter(event => event.snapshot) + .map(event => { + let key = `${event.date.getTime()}-${event.snapshot.y}`; + let tooltip = [ + `<span class="nowrap">${event.version}</span>`, + `<span class="nowrap">${moment(event.date).format('LL')}</span>`, + `<span class="nowrap">${event.snapshot.y ? this.props.formatValue(event.snapshot.y) : '—'}</span>` + ].join('<br>'); + return <circle key={key} className="line-chart-point" + r="4" cx={xScale(event.snapshot.x)} cy={yScale(event.snapshot.y)} + data-toggle="tooltip" data-title={tooltip}/>; + }); + return <g>{points}</g>; + }, + + render () { + if (!this.state.width || !this.state.height) { + return <div/>; + } + + let availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3]; + let availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2]; + + let xScale = d3.time.scale() + .domain(d3.extent(this.props.data, d => d.x)) + .range([0, availableWidth]) + .clamp(true); + let yScale = d3.scale.linear() + .range([availableHeight, 0]) + .domain([0, d3.max(this.props.data, d => d.y)]) + .nice(); + + return <svg className="line-chart" width={this.state.width} height={this.state.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.renderLine(xScale, yScale)} + {this.renderEvents(xScale, yScale)} + </g> + </svg>; + } +}); diff --git a/server/sonar-web/src/main/js/components/charts/bar-chart.js b/server/sonar-web/src/main/js/components/charts/bar-chart.js index f82a9c7cda2..7ce292921bc 100644 --- a/server/sonar-web/src/main/js/components/charts/bar-chart.js +++ b/server/sonar-web/src/main/js/components/charts/bar-chart.js @@ -1,7 +1,7 @@ import d3 from 'd3'; import React from 'react'; -import { ResizeMixin } from './mixins/resize-mixin'; +import { ResizeMixin } from './../mixins/resize-mixin'; import { TooltipsMixin } from './../mixins/tooltips-mixin'; export const BarChart = React.createClass({ diff --git a/server/sonar-web/src/main/js/components/charts/bubble-chart.js b/server/sonar-web/src/main/js/components/charts/bubble-chart.js index 9e3facaa74d..2dd929c7c4c 100644 --- a/server/sonar-web/src/main/js/components/charts/bubble-chart.js +++ b/server/sonar-web/src/main/js/components/charts/bubble-chart.js @@ -1,7 +1,7 @@ import d3 from 'd3'; import React from 'react'; -import { ResizeMixin } from './mixins/resize-mixin'; +import { ResizeMixin } from './../mixins/resize-mixin'; import { TooltipsMixin } from './../mixins/tooltips-mixin'; diff --git a/server/sonar-web/src/main/js/components/charts/histogram.js b/server/sonar-web/src/main/js/components/charts/histogram.js new file mode 100644 index 00000000000..d406d14eedf --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/histogram.js @@ -0,0 +1,93 @@ +import d3 from 'd3'; +import React from 'react'; + +import { ResizeMixin } from './../mixins/resize-mixin'; +import { TooltipsMixin } from './../mixins/tooltips-mixin'; + +export const Histogram = React.createClass({ + mixins: [ResizeMixin, TooltipsMixin], + + propTypes: { + data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + yTicks: React.PropTypes.arrayOf(React.PropTypes.any), + yValues: React.PropTypes.arrayOf(React.PropTypes.any), + width: React.PropTypes.number, + height: React.PropTypes.number, + padding: React.PropTypes.arrayOf(React.PropTypes.number), + barsHeight: React.PropTypes.number + }, + + getDefaultProps() { + return { + xTicks: [], + xValues: [], + padding: [10, 10, 10, 10], + barsHeight: 10 + }; + }, + + getInitialState () { + return { width: this.props.width, height: this.props.height }; + }, + + renderTicks (xScale, yScale) { + if (!this.props.yTicks.length) { + return null; + } + let ticks = this.props.yTicks.map((tick, index) => { + let point = this.props.data[index]; + let x = xScale.range()[0]; + let y = Math.round(yScale(point.y) + yScale.rangeBand() / 2 + this.props.barsHeight / 2); + return <text key={index} className="bar-chart-tick histogram-tick" x={x} y={y} dx="-1em" dy="0.3em">{tick}</text>; + }); + return <g>{ticks}</g>; + }, + + renderValues (xScale, yScale) { + if (!this.props.yValues.length) { + return null; + } + let ticks = this.props.yValues.map((value, index) => { + let point = this.props.data[index]; + let x = xScale(point.x); + let y = Math.round(yScale(point.y) + yScale.rangeBand() / 2 + this.props.barsHeight / 2); + return <text key={index} className="bar-chart-tick histogram-value" x={x} y={y} dx="1em" dy="0.3em">{value}</text>; + }); + return <g>{ticks}</g>; + }, + + renderBars (xScale, yScale) { + let bars = this.props.data.map((d, index) => { + let x = Math.round(xScale(d.x)) + /* minimum bar width */ 1; + let y = Math.round(yScale(d.y) + yScale.rangeBand() / 2); + return <rect key={index} className="bar-chart-bar" + x={0} y={y} width={x} height={this.props.barsHeight}/>; + }); + return <g>{bars}</g>; + }, + + render () { + if (!this.state.width || !this.state.height) { + return <div/>; + } + + let availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3]; + let availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2]; + + let maxX = d3.max(this.props.data, d => d.x); + let xScale = d3.scale.linear() + .domain([0, maxX]) + .range([0, availableWidth]); + let yScale = d3.scale.ordinal() + .domain(this.props.data.map(d => d.y)) + .rangeRoundBands([0, availableHeight]); + + return <svg className="bar-chart" width={this.state.width} height={this.state.height}> + <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> + {this.renderTicks(xScale, yScale)} + {this.renderValues(xScale, yScale)} + {this.renderBars(xScale, yScale)} + </g> + </svg>; + } +}); diff --git a/server/sonar-web/src/main/js/components/charts/line-chart.js b/server/sonar-web/src/main/js/components/charts/line-chart.js index eeaed5e88a9..375931ea9f5 100644 --- a/server/sonar-web/src/main/js/components/charts/line-chart.js +++ b/server/sonar-web/src/main/js/components/charts/line-chart.js @@ -1,7 +1,7 @@ import d3 from 'd3'; import React from 'react'; -import { ResizeMixin } from './mixins/resize-mixin'; +import { ResizeMixin } from './../mixins/resize-mixin'; import { TooltipsMixin } from './../mixins/tooltips-mixin'; diff --git a/server/sonar-web/src/main/js/components/charts/treemap.js b/server/sonar-web/src/main/js/components/charts/treemap.js index 24bcff49ce2..de56351ff7e 100644 --- a/server/sonar-web/src/main/js/components/charts/treemap.js +++ b/server/sonar-web/src/main/js/components/charts/treemap.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import d3 from 'd3'; import React from 'react'; -import { ResizeMixin } from './mixins/resize-mixin'; +import { ResizeMixin } from './../mixins/resize-mixin'; import { TooltipsMixin } from './../mixins/tooltips-mixin'; @@ -81,14 +81,11 @@ export const Treemap = React.createClass({ return <div> </div>; } - let sizeScale = d3.scale.linear() - .domain([0, d3.max(this.props.items, d => d.size)]) - .range([5, 45]); let treemap = d3.layout.treemap() .round(true) - .value(d => sizeScale(d.size)) + .value(d => d.size) .sort((a, b) => a.value - b.value) - .size([this.state.width, 360]); + .size([this.state.width, this.state.height]); let nodes = treemap .nodes({ children: this.props.items }) .filter(d => !d.children) diff --git a/server/sonar-web/src/main/js/components/charts/mixins/resize-mixin.js b/server/sonar-web/src/main/js/components/mixins/resize-mixin.js index ebd7360fe07..ebd7360fe07 100644 --- a/server/sonar-web/src/main/js/components/charts/mixins/resize-mixin.js +++ b/server/sonar-web/src/main/js/components/mixins/resize-mixin.js diff --git a/server/sonar-web/src/main/js/components/router/router.js b/server/sonar-web/src/main/js/components/router/router.js new file mode 100644 index 00000000000..7489700841e --- /dev/null +++ b/server/sonar-web/src/main/js/components/router/router.js @@ -0,0 +1,48 @@ +let listener; + + +export const RouterMixin = { + getDefaultProps() { + return { urlRoot: '/' }; + }, + + getInitialState() { + return { route: this.getRoute() }; + }, + + getRoute() { + let path = window.location.pathname; + if (path.indexOf(this.props.urlRoot) === 0) { + return path.substr(this.props.urlRoot.length); + } else { + return null; + } + }, + + componentDidMount () { + listener = this; + window.addEventListener('popstate', this.handleRouteChange); + }, + + componentWillUnmount() { + window.removeEventListener('popstate', this.handleRouteChange); + }, + + handleRouteChange() { + let route = this.getRoute(); + this.setState({ route }); + }, + + navigate (route) { + let url = this.props.urlRoot + route + window.location.search + window.location.hash; + window.history.pushState({ route }, document.title, url); + this.setState({ route }); + } +}; + + +export function navigate (route) { + if (listener) { + listener.navigate(route); + } +} diff --git a/server/sonar-web/src/main/js/helpers/measures.js b/server/sonar-web/src/main/js/helpers/measures.js index 0e9433295f6..23a27e9270e 100644 --- a/server/sonar-web/src/main/js/helpers/measures.js +++ b/server/sonar-web/src/main/js/helpers/measures.js @@ -1,4 +1,5 @@ import numeral from 'numeral'; +import _ from 'underscore'; /** @@ -23,6 +24,33 @@ export function formatMeasureVariation (value, type) { } +/** + * Return a localized metric name + * @param {string} metricKey + * @returns {string} + */ +export function localizeMetric (metricKey) { + return window.t('metric', metricKey, 'name'); +} + + +/** + * Group list of metrics by their domain + * @param {Array} metrics + * @returns {Array} + */ +export function groupByDomain (metrics) { + let groupedMetrics = _.groupBy(metrics, 'domain'); + let domains = _.map(groupedMetrics, (metricList, domain) => { + return { + domain: domain, + metrics: _.sortBy(metricList, 'name') + }; + }); + return _.sortBy(domains, 'domain'); +} + + /* * Helpers */ diff --git a/server/sonar-web/src/main/less/init/icons.less b/server/sonar-web/src/main/less/init/icons.less index 3b2fff04283..5ec80ef3073 100644 --- a/server/sonar-web/src/main/less/init/icons.less +++ b/server/sonar-web/src/main/less/init/icons.less @@ -389,7 +389,7 @@ a[class^="icon-"], a[class*=" icon-"] { } .icon-plus:before { content: "\f067"; - font-size: @iconFontSize; + font-size: @iconSmallFontSize; } .icon-link:before { content: "\f127"; diff --git a/server/sonar-web/src/main/less/pages/overview.less b/server/sonar-web/src/main/less/pages/overview.less index 2e519588943..a2142d9be4d 100644 --- a/server/sonar-web/src/main/less/pages/overview.less +++ b/server/sonar-web/src/main/less/pages/overview.less @@ -11,7 +11,6 @@ justify-content: space-between; width: 100%; min-height: ~"calc(100vh - @{navbarGlobalHeight} - @{navbarContextHeight} - @{pageFooterHeight})"; - overflow-x: hidden; animation: fadeIn 0.5s forwards; } @@ -136,6 +135,7 @@ align-items: baseline; justify-content: space-between; margin-bottom: 10px; + line-height: @formControlHeight; .overview-title { flex: 1; @@ -225,7 +225,220 @@ } } +/* + * Detailed Pages + */ + +.overview-detailed-page { + flex: 1; +} + +.overview-detailed-measures-list { + border: 1px solid @barBorderColor; + background-color: #fff; + overflow: hidden; +} + +.overview-detailed-measure { + display: flex; + background-color: #fff; +} + +.overview-detailed-measure-nutshell, +.overview-detailed-measure-leak { + position: relative; + padding: 7px 10px; +} + +.overview-detailed-measure-nutshell { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + flex: 3; +} + +.overview-detailed-measure-leak { + flex: 1; + background-color: #fffae7; + text-align: center; +} + +.overview-detailed-measure-value { + font-size: 16px; +} + +.overview-detailed-layout-size { + display: flex; + justify-content: space-between; + margin: 0 -10px; + + .overview-detailed-layout-column { + flex: 1; + max-width: 560px; + } +} + +.overview-detailed-layout-column { + padding: 0 10px; +} + +.overview-legend-nutshell, +.overview-legend-leak { + display: inline-block; + vertical-align: middle; + width: 16px; + height: 16px; + margin-top: -2px; + margin-left: 16px; + margin-right: 8px; + border: 1px solid @barBorderColor; + box-sizing: border-box; +} + +.overview-legend-nutshell { + background-color: #fff; +} + +.overview-legend-leak { + background-color: #fffae7; +} + +/* + * Charts + */ + +.overview-domain-charts { + display: flex; + + .overview-domain-chart { + flex: 1; + } +} + +.overview-domain-chart { + .overview-title { + display: inline-block; + margin-right: 20px; + } +} + +.overview-bar-chart { + width: 100%; + padding-top: 10px; + padding-bottom: 15px; +} + +.bar-chart-bar { + fill: @blue; +} + +.bar-chart-tick { + fill: @secondFontColor; + font-size: 11px; + text-anchor: middle; +} + +.histogram-tick { + text-anchor: end; +} + +.histogram-value { + text-anchor: start; +} + +.overview-timeline { + padding: 10px; + border: 1px solid @barBorderColor; + box-sizing: border-box; + background-color: #fff; + + .line-chart-path { + fill: none; + stroke: @blue; + stroke-width: 2px; + } + + .line-chart-point { + fill: #fff; + stroke: @darkBlue; + stroke-width: 2px; + } + + .line-chart-backdrop { + fill: #fffae7; + } + + .line-chart-tick { + fill: @secondFontColor; + font-size: 11px; + text-anchor: middle; + } + + .line-chart-tick-x { + text-anchor: end; + } + + .line-chart-tick-x-right { + text-anchor: start; + } + .line-chart-grid { + shape-rendering: crispedges; + stroke: #eee; + } +} + +.overview-timeline-1 { + .line-chart-path { + stroke: @purple; + } + + .line-chart-point { + stroke: darken(@purple, 20%); + } +} + +.overview-timeline-sample { + display: inline-block; + vertical-align: middle; + width: 16px; + height: 2px; + margin-right: 8px; +} + +.overview-timeline-sample-0 { + background-color: @blue; +} + +.overview-timeline-sample-1 { + background-color: @purple; +} + +.overview-timeline-chart { + text-align: center; +} + +.overview-timeline-chart + .overview-timeline-chart { + margin-top: 40px; +} + +.overview-timeline-select { + width: 12em; + height: @formControlHeight; + line-height: @formControlHeight; + border: 1px solid #cdcdcd; + background: none; +} + +.overview-treemap { +} + +.overview-chart-placeholder { + display: flex; + justify-content: center; + align-items: center; +} /* * Responsive Stuff @@ -246,13 +459,15 @@ } } - - /* * Animations */ @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } diff --git a/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb b/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb index 098d8cccf9a..36e2931ee2f 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb +++ b/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb @@ -33,6 +33,7 @@ ActionController::Routing::Routes.draw do |map| map.connect 'api_documentation/*other', :controller => 'api_documentation', :action => 'index' map.connect 'quality_gates/*other', :controller => 'quality_gates', :action => 'index' + map.connect 'overview/*other', :controller => 'overview', :action => 'index' # Install the default route as the lowest priority. map.connect ':controller/:action/:id', :requirements => { :id => /.*/ } diff --git a/server/sonar-web/tests/apps/overview-test.js b/server/sonar-web/tests/apps/overview-test.js index 2ba57a13ff5..2bfea7f419a 100644 --- a/server/sonar-web/tests/apps/overview-test.js +++ b/server/sonar-web/tests/apps/overview-test.js @@ -3,9 +3,9 @@ import ReactDOM from 'react-dom'; import TestUtils from 'react-addons-test-utils'; import { expect } from 'chai'; -import Gate from '../../src/main/js/apps/overview/gate/gate'; -import GateConditions from '../../src/main/js/apps/overview/gate/gate-conditions'; -import GateCondition from '../../src/main/js/apps/overview/gate/gate-condition'; +import Gate from '../../src/main/js/apps/overview/main/gate/gate'; +import GateConditions from '../../src/main/js/apps/overview/main/gate/gate-conditions'; +import GateCondition from '../../src/main/js/apps/overview/main/gate/gate-condition'; describe('Overview', function () { |