From c754acd1a7aa7e4b9cda202d7baf210fa8693e86 Mon Sep 17 00:00:00 2001 From: Stas Vilchik Date: Tue, 20 Oct 2015 16:54:26 +0200 Subject: [PATCH] SONAR-6359 add detailed "Issues & Technical Debt" panel for the "Overview" main page --- server/sonar-web/package.json | 2 + .../sonar-web/src/main/js/api/components.js | 19 ++ server/sonar-web/src/main/js/api/events.js | 7 + server/sonar-web/src/main/js/api/issues.js | 27 +++ .../sonar-web/src/main/js/api/time-machine.js | 7 + .../src/main/js/apps/overview/app.js | 16 +- .../src/main/js/apps/overview/card.js | 7 - .../main/js/apps/overview/domain/header.js | 7 + .../src/main/js/apps/overview/formatting.js | 21 ++ .../src/main/js/apps/overview/general/card.js | 19 ++ .../js/apps/overview/{ => general}/cards.js | 0 .../js/apps/overview/{ => general}/empty.js | 0 .../overview/{ => general}/gate-condition.js | 6 +- .../overview/{ => general}/gate-conditions.js | 0 .../apps/overview/{ => general}/gate-empty.js | 0 .../js/apps/overview/{ => general}/gate.js | 0 .../overview/{ => general}/leak-coverage.js | 8 +- .../apps/overview/{ => general}/leak-dups.js | 4 +- .../overview/{ => general}/leak-issues.js | 12 +- .../apps/overview/{ => general}/leak-size.js | 2 +- .../js/apps/overview/{ => general}/leak.js | 2 +- .../src/main/js/apps/overview/general/main.js | 97 ++++++++ .../{ => general}/nutshell-coverage.js | 6 +- .../overview/{ => general}/nutshell-dups.js | 6 +- .../overview/{ => general}/nutshell-issues.js | 16 +- .../overview/{ => general}/nutshell-size.js | 4 +- .../apps/overview/{ => general}/nutshell.js | 7 +- .../main/js/apps/overview/issues/assignees.js | 28 +++ .../js/apps/overview/issues/bubble-chart.js | 105 +++++++++ .../src/main/js/apps/overview/issues/main.js | 66 ++++++ .../js/apps/overview/issues/severities.js | 35 +++ .../src/main/js/apps/overview/issues/tags.js | 24 ++ .../main/js/apps/overview/issues/timeline.js | 147 ++++++++++++ .../main/js/apps/overview/issues/treemap.js | 99 ++++++++ .../src/main/js/apps/overview/main.js | 128 +++-------- .../main/js/components/charts/bubble-chart.js | 213 ++++++++++++++++++ .../main/js/components/charts/line-chart.js | 156 +++++++++++++ .../src/main/js/components/charts/timeline.js | 85 +++++++ .../src/main/js/components/charts/treemap.js | 143 ++++++++++++ .../main/js/components/charts/word-cloud.js | 59 +++++ .../js/components/shared/assignee-helper.js | 11 + .../js/components/shared/severity-helper.js | 13 +- server/sonar-web/src/main/js/helpers/Url.js | 11 +- .../src/main/less/components/columns.less | 17 ++ .../src/main/less/components/graphics.less | 13 ++ .../src/main/less/components/tooltips.less | 1 + .../src/main/less/pages/overview.less | 145 +++++++++++- 47 files changed, 1637 insertions(+), 164 deletions(-) create mode 100644 server/sonar-web/src/main/js/api/events.js create mode 100644 server/sonar-web/src/main/js/api/issues.js create mode 100644 server/sonar-web/src/main/js/api/time-machine.js delete mode 100644 server/sonar-web/src/main/js/apps/overview/card.js create mode 100644 server/sonar-web/src/main/js/apps/overview/domain/header.js create mode 100644 server/sonar-web/src/main/js/apps/overview/formatting.js create mode 100644 server/sonar-web/src/main/js/apps/overview/general/card.js rename server/sonar-web/src/main/js/apps/overview/{ => general}/cards.js (100%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/empty.js (100%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/gate-condition.js (89%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/gate-conditions.js (100%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/gate-empty.js (100%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/gate.js (100%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/leak-coverage.js (87%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/leak-dups.js (92%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/leak-issues.js (89%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/leak-size.js (93%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/leak.js (95%) create mode 100644 server/sonar-web/src/main/js/apps/overview/general/main.js rename server/sonar-web/src/main/js/apps/overview/{ => general}/nutshell-coverage.js (90%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/nutshell-dups.js (91%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/nutshell-issues.js (85%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/nutshell-size.js (92%) rename server/sonar-web/src/main/js/apps/overview/{ => general}/nutshell.js (79%) create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/assignees.js create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/bubble-chart.js create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/main.js create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/severities.js create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/tags.js create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/timeline.js create mode 100644 server/sonar-web/src/main/js/apps/overview/issues/treemap.js create mode 100644 server/sonar-web/src/main/js/components/charts/bubble-chart.js create mode 100644 server/sonar-web/src/main/js/components/charts/line-chart.js create mode 100644 server/sonar-web/src/main/js/components/charts/timeline.js create mode 100644 server/sonar-web/src/main/js/components/charts/treemap.js create mode 100644 server/sonar-web/src/main/js/components/charts/word-cloud.js create mode 100644 server/sonar-web/src/main/js/components/shared/assignee-helper.js diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index 2d21537cd6d..ba159c70afb 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -13,7 +13,9 @@ "browserify": "11.2.0", "browserify-shim": "3.8.10", "chai": "3.3.0", + "classnames": "^2.2.0", "del": "2.0.2", + "document-offset": "^1.0.4", "event-stream": "3.3.1", "glob": "5.0.15", "gulp": "3.9.0", diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index 8fe69f9c6d2..a4eb949a2d7 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -1,3 +1,4 @@ +import { getJSON } from '../helpers/request.js'; import $ from 'jquery'; export function getComponents (data) { @@ -25,3 +26,21 @@ export function createProject (options) { options.type = 'POST'; return $.ajax(options); } + +export function getChildren (componentKey, metrics = []) { + let url = baseUrl + '/api/resources/index'; + let data = { resource: componentKey, metrics: metrics.join(','), depth: 1 }; + return getJSON(url, data); +} + +export function getFiles (componentKey, metrics = []) { + // due to the limitation of the WS we can not ask qualifiers=FIL, + // in this case the WS does not return measures + // so the filtering by a qualifier is done manually + + let url = baseUrl + '/api/resources/index'; + let data = { resource: componentKey, metrics: metrics.join(','), depth: -1 }; + return getJSON(url, data).then(r => { + return r.filter(component => component.qualifier === 'FIL'); + }); +} diff --git a/server/sonar-web/src/main/js/api/events.js b/server/sonar-web/src/main/js/api/events.js new file mode 100644 index 00000000000..11852bef775 --- /dev/null +++ b/server/sonar-web/src/main/js/api/events.js @@ -0,0 +1,7 @@ +import { getJSON } from '../helpers/request.js'; + +export function getEvents (componentKey, categories) { + let url = baseUrl + '/api/events'; + let data = { resource: componentKey, categories }; + return getJSON(url, data); +} diff --git a/server/sonar-web/src/main/js/api/issues.js b/server/sonar-web/src/main/js/api/issues.js new file mode 100644 index 00000000000..5d2259e6855 --- /dev/null +++ b/server/sonar-web/src/main/js/api/issues.js @@ -0,0 +1,27 @@ +import _ from 'underscore'; +import { getJSON } from '../helpers/request.js'; + +function getFacet (query, facet) { + let url = baseUrl + '/api/issues/search'; + let data = _.extend({}, query, { facets: facet, ps: 1, additionalFields: '_all' }); + return getJSON(url, data).then(r => { + return { facet: r.facets[0].values, response: r }; + }); +} + +export function getSeverities (query) { + return getFacet(query, 'severities').then(r => r.facet); +} + +export function getTags (query) { + return getFacet(query, 'tags').then(r => r.facet); +} + +export function getAssignees (query) { + return getFacet(query, 'assignees').then(r => { + return r.facet.map(item => { + let user = _.findWhere(r.response.users, { login: item.val }); + return _.extend(item, { user }); + }); + }); +} diff --git a/server/sonar-web/src/main/js/api/time-machine.js b/server/sonar-web/src/main/js/api/time-machine.js new file mode 100644 index 00000000000..200cfaafbad --- /dev/null +++ b/server/sonar-web/src/main/js/api/time-machine.js @@ -0,0 +1,7 @@ +import { getJSON } from '../helpers/request.js'; + +export function getTimeMachineData (componentKey, metrics) { + let url = baseUrl + '/api/timemachine/index'; + let data = { resource: componentKey, metrics }; + return getJSON(url, data); +} 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 80bc990a965..6b661b9b592 100644 --- a/server/sonar-web/src/main/js/apps/overview/app.js +++ b/server/sonar-web/src/main/js/apps/overview/app.js @@ -2,24 +2,14 @@ import $ from 'jquery'; import _ from 'underscore'; import React from 'react'; import Main from './main'; -import Empty from './empty'; class App { - start(options) { + start (options) { let opts = _.extend({}, options, window.sonarqube.overview); _.extend(opts.component, options.component); $('html').toggleClass('dashboard-page', opts.component.hasSnapshot); - window.requestMessages().done(() => { - let el = document.querySelector(opts.el); - let inner = opts.component.hasSnapshot ? ( -
- ) : ; - React.render(inner, el); - }); + let el = document.querySelector(opts.el); + React.render(
, el); } } diff --git a/server/sonar-web/src/main/js/apps/overview/card.js b/server/sonar-web/src/main/js/apps/overview/card.js deleted file mode 100644 index a22146246d3..00000000000 --- a/server/sonar-web/src/main/js/apps/overview/card.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -export default React.createClass({ - render() { - return
  • {this.props.children}
  • ; - } -}); diff --git a/server/sonar-web/src/main/js/apps/overview/domain/header.js b/server/sonar-web/src/main/js/apps/overview/domain/header.js new file mode 100644 index 00000000000..16af08d9d13 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/domain/header.js @@ -0,0 +1,7 @@ +import React from 'react'; + +export class DomainHeader extends React.Component { + render () { + return

    {this.props.title}

    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/formatting.js b/server/sonar-web/src/main/js/apps/overview/formatting.js new file mode 100644 index 00000000000..7a91cbcd5f1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/formatting.js @@ -0,0 +1,21 @@ +const METRIC_TYPES = { + 'violations': 'SHORT_INT', + 'blocker_violations': 'SHORT_INT', + 'critical_violations': 'SHORT_INT', + 'major_violations': 'SHORT_INT', + 'minor_violations': 'SHORT_INT', + 'info_violations': 'SHORT_INT', + 'confirmed_issues': 'SHORT_INT', + 'false_positive_issues': 'SHORT_INT', + 'open_issues': 'SHORT_INT', + 'reopened_issues': 'SHORT_INT', + 'sqale_index': 'SHORT_WORK_DUR', + 'sqale_debt_ratio': 'PERCENT', + 'sqale_rating': 'RATING', + 'lines': 'SHORT_INT' +}; + +export function formatMeasure (value, metric) { + let type = METRIC_TYPES[metric]; + return type ? window.formatMeasure(value, type) : value; +} diff --git a/server/sonar-web/src/main/js/apps/overview/general/card.js b/server/sonar-web/src/main/js/apps/overview/general/card.js new file mode 100644 index 00000000000..1d1d563db8d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/general/card.js @@ -0,0 +1,19 @@ +import React from 'react'; +import classNames from 'classnames'; + +export default React.createClass({ + handleClick() { + if (this.props.linkTo) { + let tab = React.findDOMNode(this); + this.props.onRoute(this.props.linkTo, tab); + } + }, + + render() { + let classes = classNames('overview-card', { + 'overview-card-section': this.props.linkTo, + 'active': this.props.active + }); + return
  • {this.props.children}
  • ; + } +}); diff --git a/server/sonar-web/src/main/js/apps/overview/cards.js b/server/sonar-web/src/main/js/apps/overview/general/cards.js similarity index 100% rename from server/sonar-web/src/main/js/apps/overview/cards.js rename to server/sonar-web/src/main/js/apps/overview/general/cards.js diff --git a/server/sonar-web/src/main/js/apps/overview/empty.js b/server/sonar-web/src/main/js/apps/overview/general/empty.js similarity index 100% rename from server/sonar-web/src/main/js/apps/overview/empty.js rename to server/sonar-web/src/main/js/apps/overview/general/empty.js diff --git a/server/sonar-web/src/main/js/apps/overview/gate-condition.js b/server/sonar-web/src/main/js/apps/overview/general/gate-condition.js similarity index 89% rename from server/sonar-web/src/main/js/apps/overview/gate-condition.js rename to server/sonar-web/src/main/js/apps/overview/general/gate-condition.js index 2cc428253e6..eb947c8eaf7 100644 --- a/server/sonar-web/src/main/js/apps/overview/gate-condition.js +++ b/server/sonar-web/src/main/js/apps/overview/general/gate-condition.js @@ -1,7 +1,7 @@ import React from 'react'; -import Measure from './helpers/measure'; -import { periodLabel, getPeriodDate } from './helpers/period-label'; -import DrilldownLink from './helpers/drilldown-link'; +import Measure from './../helpers/measure'; +import { periodLabel, getPeriodDate } from './../helpers/period-label'; +import DrilldownLink from './../helpers/drilldown-link'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/gate-conditions.js b/server/sonar-web/src/main/js/apps/overview/general/gate-conditions.js similarity index 100% rename from server/sonar-web/src/main/js/apps/overview/gate-conditions.js rename to server/sonar-web/src/main/js/apps/overview/general/gate-conditions.js diff --git a/server/sonar-web/src/main/js/apps/overview/gate-empty.js b/server/sonar-web/src/main/js/apps/overview/general/gate-empty.js similarity index 100% rename from server/sonar-web/src/main/js/apps/overview/gate-empty.js rename to server/sonar-web/src/main/js/apps/overview/general/gate-empty.js diff --git a/server/sonar-web/src/main/js/apps/overview/gate.js b/server/sonar-web/src/main/js/apps/overview/general/gate.js similarity index 100% rename from server/sonar-web/src/main/js/apps/overview/gate.js rename to server/sonar-web/src/main/js/apps/overview/general/gate.js diff --git a/server/sonar-web/src/main/js/apps/overview/leak-coverage.js b/server/sonar-web/src/main/js/apps/overview/general/leak-coverage.js similarity index 87% rename from server/sonar-web/src/main/js/apps/overview/leak-coverage.js rename to server/sonar-web/src/main/js/apps/overview/general/leak-coverage.js index 0d502439796..5af186b9933 100644 --- a/server/sonar-web/src/main/js/apps/overview/leak-coverage.js +++ b/server/sonar-web/src/main/js/apps/overview/general/leak-coverage.js @@ -1,9 +1,9 @@ import React from 'react'; import Card from './card'; -import Measure from './helpers/measure'; -import MeasureVariation from './helpers/measure-variation'; -import DrilldownLink from './helpers/drilldown-link'; -import Donut from './helpers/donut'; +import Measure from './../helpers/measure'; +import MeasureVariation from './../helpers/measure-variation'; +import DrilldownLink from './../helpers/drilldown-link'; +import Donut from './../helpers/donut'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/leak-dups.js b/server/sonar-web/src/main/js/apps/overview/general/leak-dups.js similarity index 92% rename from server/sonar-web/src/main/js/apps/overview/leak-dups.js rename to server/sonar-web/src/main/js/apps/overview/general/leak-dups.js index a34555fd892..bdd2188c4df 100644 --- a/server/sonar-web/src/main/js/apps/overview/leak-dups.js +++ b/server/sonar-web/src/main/js/apps/overview/general/leak-dups.js @@ -1,7 +1,7 @@ import React from 'react'; import Card from './card'; -import MeasureVariation from './helpers/measure-variation'; -import Donut from './helpers/donut'; +import MeasureVariation from './../helpers/measure-variation'; +import Donut from './../helpers/donut'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/leak-issues.js b/server/sonar-web/src/main/js/apps/overview/general/leak-issues.js similarity index 89% rename from server/sonar-web/src/main/js/apps/overview/leak-issues.js rename to server/sonar-web/src/main/js/apps/overview/general/leak-issues.js index 61443d6f1ce..fc33b7c3065 100644 --- a/server/sonar-web/src/main/js/apps/overview/leak-issues.js +++ b/server/sonar-web/src/main/js/apps/overview/general/leak-issues.js @@ -1,12 +1,12 @@ import moment from 'moment'; import React from 'react'; import Card from './card'; -import Measure from './helpers/measure'; -import MeasureVariation from './helpers/measure-variation'; -import IssuesLink from './helpers/issues-link'; -import SeverityIcon from '../../components/shared/severity-icon'; -import StatusIcon from '../../components/shared/status-icon'; -import {getPeriodDate} from './helpers/period-label'; +import Measure from './../helpers/measure'; +import MeasureVariation from './../helpers/measure-variation'; +import IssuesLink from './../helpers/issues-link'; +import SeverityIcon from '../../../components/shared/severity-icon'; +import StatusIcon from '../../../components/shared/status-icon'; +import {getPeriodDate} from './../helpers/period-label'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/leak-size.js b/server/sonar-web/src/main/js/apps/overview/general/leak-size.js similarity index 93% rename from server/sonar-web/src/main/js/apps/overview/leak-size.js rename to server/sonar-web/src/main/js/apps/overview/general/leak-size.js index d7df01caf33..77162374312 100644 --- a/server/sonar-web/src/main/js/apps/overview/leak-size.js +++ b/server/sonar-web/src/main/js/apps/overview/general/leak-size.js @@ -1,6 +1,6 @@ import React from 'react'; import Card from './card'; -import MeasureVariation from './helpers/measure-variation'; +import MeasureVariation from './../helpers/measure-variation'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/leak.js b/server/sonar-web/src/main/js/apps/overview/general/leak.js similarity index 95% rename from server/sonar-web/src/main/js/apps/overview/leak.js rename to server/sonar-web/src/main/js/apps/overview/general/leak.js index f629153f2ff..20db93ff6c3 100644 --- a/server/sonar-web/src/main/js/apps/overview/leak.js +++ b/server/sonar-web/src/main/js/apps/overview/general/leak.js @@ -5,7 +5,7 @@ import LeakIssues from './leak-issues'; import LeakCoverage from './leak-coverage'; import LeakSize from './leak-size'; import LeakDups from './leak-dups'; -import {periodLabel} from './helpers/period-label'; +import {periodLabel} from './../helpers/period-label'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/general/main.js b/server/sonar-web/src/main/js/apps/overview/general/main.js new file mode 100644 index 00000000000..a55eacf2d1d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/general/main.js @@ -0,0 +1,97 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import moment from 'moment'; +import React from 'react'; +import Gate from './gate'; +import Leak from './leak'; +import Nutshell from './nutshell'; +import {getPeriodDate} from './../helpers/period-label'; + +export default React.createClass({ + getInitialState() { + return { leak: this.props.leak, measures: this.props.measures }; + }, + + componentDidMount() { + if (this._hasWaterLeak()) { + this.requestLeakIssues(); + this.requestLeakDebt(); + } + this.requestNutshellIssues(); + this.requestNutshellDebt(); + }, + + _hasWaterLeak() { + return !!_.findWhere(this.props.component.periods, { index: '3' }); + }, + + _requestIssues(data) { + let url = `${baseUrl}/api/issues/search`; + data.ps = 1; + data.componentUuids = this.props.component.id; + return $.get(url, data); + }, + + requestLeakIssues() { + let createdAfter = moment(getPeriodDate(this.props.component.periods, '3')).format('YYYY-MM-DDTHH:mm:ssZZ'); + this._requestIssues({ resolved: 'false', createdAfter, facets: 'severities,statuses' }).done(r => { + let + severitiesFacet = _.findWhere(r.facets, { property: 'severities' }).values, + statusesFacet = _.findWhere(r.facets, { property: 'statuses' }).values; + + this.setState({ + leak: _.extend({}, this.state.leak, { + newIssues: r.total, + newBlockerIssues: _.findWhere(severitiesFacet, { val: 'BLOCKER' }).count, + newCriticalIssues: _.findWhere(severitiesFacet, { val: 'CRITICAL' }).count, + newOpenIssues: _.findWhere(statusesFacet, { val: 'OPEN' }).count, + newReopenedIssues: _.findWhere(statusesFacet, { val: 'REOPENED' }).count + }) + }); + }); + }, + + requestNutshellIssues() { + this._requestIssues({ resolved: 'false', facets: 'severities,statuses' }).done(r => { + let + severitiesFacet = _.findWhere(r.facets, { property: 'severities' }).values, + statusesFacet = _.findWhere(r.facets, { property: 'statuses' }).values; + + this.setState({ + measures: _.extend({}, this.state.measures, { + issues: r.total, + blockerIssues: _.findWhere(severitiesFacet, { val: 'BLOCKER' }).count, + criticalIssues: _.findWhere(severitiesFacet, { val: 'CRITICAL' }).count, + openIssues: _.findWhere(statusesFacet, { val: 'OPEN' }).count, + reopenedIssues: _.findWhere(statusesFacet, { val: 'REOPENED' }).count + }) + }); + }); + }, + + requestLeakDebt() { + let createdAfter = moment(getPeriodDate(this.props.component.periods, '3')).format('YYYY-MM-DDTHH:mm:ssZZ'); + this._requestIssues({ resolved: 'false', createdAfter, facets: 'severities', facetMode: 'debt' }).done(r => { + this.setState({ + leak: _.extend({}, this.state.leak, { newDebt: r.debtTotal }) + }); + }); + }, + + requestNutshellDebt() { + this._requestIssues({ resolved: 'false', facets: 'severities', facetMode: 'debt' }).done(r => { + this.setState({ + measures: _.extend({}, this.state.measures, { debt: r.debtTotal }) + }); + }); + }, + + render() { + return
    + + + +
    ; + } +}); diff --git a/server/sonar-web/src/main/js/apps/overview/nutshell-coverage.js b/server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js similarity index 90% rename from server/sonar-web/src/main/js/apps/overview/nutshell-coverage.js rename to server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js index e478a715259..9de1e53825d 100644 --- a/server/sonar-web/src/main/js/apps/overview/nutshell-coverage.js +++ b/server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js @@ -1,8 +1,8 @@ import React from 'react'; import Card from './card'; -import Measure from './helpers/measure'; -import DrilldownLink from './helpers/drilldown-link'; -import Donut from './helpers/donut'; +import Measure from './../helpers/measure'; +import DrilldownLink from './../helpers/drilldown-link'; +import Donut from './../helpers/donut'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/nutshell-dups.js b/server/sonar-web/src/main/js/apps/overview/general/nutshell-dups.js similarity index 91% rename from server/sonar-web/src/main/js/apps/overview/nutshell-dups.js rename to server/sonar-web/src/main/js/apps/overview/general/nutshell-dups.js index fd93f144b0a..71a4055b26a 100644 --- a/server/sonar-web/src/main/js/apps/overview/nutshell-dups.js +++ b/server/sonar-web/src/main/js/apps/overview/general/nutshell-dups.js @@ -1,8 +1,8 @@ import React from 'react'; import Card from './card'; -import Measure from './helpers/measure'; -import DrilldownLink from './helpers/drilldown-link'; -import Donut from './helpers/donut'; +import Measure from './../helpers/measure'; +import DrilldownLink from './../helpers/drilldown-link'; +import Donut from './../helpers/donut'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/nutshell-issues.js b/server/sonar-web/src/main/js/apps/overview/general/nutshell-issues.js similarity index 85% rename from server/sonar-web/src/main/js/apps/overview/nutshell-issues.js rename to server/sonar-web/src/main/js/apps/overview/general/nutshell-issues.js index f9ae3340dbf..8856bac3a3b 100644 --- a/server/sonar-web/src/main/js/apps/overview/nutshell-issues.js +++ b/server/sonar-web/src/main/js/apps/overview/general/nutshell-issues.js @@ -1,11 +1,11 @@ import React from 'react'; import Card from './card'; -import Measure from './helpers/measure'; -import Rating from './helpers/rating'; -import IssuesLink from './helpers/issues-link'; -import DrilldownLink from './helpers/drilldown-link'; -import SeverityIcon from '../../components/shared/severity-icon'; -import StatusIcon from '../../components/shared/status-icon'; +import Measure from './../helpers/measure'; +import Rating from './../helpers/rating'; +import IssuesLink from './../helpers/issues-link'; +import DrilldownLink from './../helpers/drilldown-link'; +import SeverityIcon from '../../../components/shared/severity-icon'; +import StatusIcon from '../../../components/shared/status-icon'; export default React.createClass({ render() { @@ -17,8 +17,10 @@ export default React.createClass({ criticalIssues = this.props.measures.criticalIssues, issuesToReview = this.props.measures.openIssues + this.props.measures.reopenedIssues; + let active = this.props.section === 'issues'; + return ( - +
    diff --git a/server/sonar-web/src/main/js/apps/overview/nutshell-size.js b/server/sonar-web/src/main/js/apps/overview/general/nutshell-size.js similarity index 92% rename from server/sonar-web/src/main/js/apps/overview/nutshell-size.js rename to server/sonar-web/src/main/js/apps/overview/general/nutshell-size.js index 5d41e291c80..9488219a2a9 100644 --- a/server/sonar-web/src/main/js/apps/overview/nutshell-size.js +++ b/server/sonar-web/src/main/js/apps/overview/general/nutshell-size.js @@ -1,7 +1,7 @@ import React from 'react'; import Card from './card'; -import Measure from './helpers/measure'; -import DrilldownLink from './helpers/drilldown-link'; +import Measure from './../helpers/measure'; +import DrilldownLink from './../helpers/drilldown-link'; export default React.createClass({ render() { diff --git a/server/sonar-web/src/main/js/apps/overview/nutshell.js b/server/sonar-web/src/main/js/apps/overview/general/nutshell.js similarity index 79% rename from server/sonar-web/src/main/js/apps/overview/nutshell.js rename to server/sonar-web/src/main/js/apps/overview/general/nutshell.js index f8c7dc39f9e..1c9c586b627 100644 --- a/server/sonar-web/src/main/js/apps/overview/nutshell.js +++ b/server/sonar-web/src/main/js/apps/overview/general/nutshell.js @@ -7,7 +7,12 @@ import NutshellDups from './nutshell-dups'; export default React.createClass({ render() { - let props = { measures: this.props.measures, component: this.props.component }; + let props = { + measures: this.props.measures, + component: this.props.component, + section: this.props.section, + onRoute: this.props.onRoute + }; return (

    {window.t('overview.project_in_a_nutshell')}

    diff --git a/server/sonar-web/src/main/js/apps/overview/issues/assignees.js b/server/sonar-web/src/main/js/apps/overview/issues/assignees.js new file mode 100644 index 00000000000..af445cc981b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/assignees.js @@ -0,0 +1,28 @@ +import React from 'react'; +import Assignee from '../../../components/shared/assignee-helper'; +import { DomainHeader } from '../domain/header'; +import { formatMeasure } from '../formatting'; +import { componentIssuesUrl } from '../../../helpers/Url'; + +export default class extends React.Component { + render () { + let rows = this.props.assignees.map(s => { + let href = componentIssuesUrl(this.props.component.key, { statuses: 'OPEN,REOPENED', assignees: s.val }); + return + + + + + {formatMeasure(s.count, 'violations')} + + ; + }); + + return
    + + + {rows} +
    +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/issues/bubble-chart.js b/server/sonar-web/src/main/js/apps/overview/issues/bubble-chart.js new file mode 100644 index 00000000000..b37c420abcf --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/bubble-chart.js @@ -0,0 +1,105 @@ +import _ from 'underscore'; +import React from 'react'; +import { BubbleChart } from '../../../components/charts/bubble-chart'; +import { getProjectUrl } from '../../../helpers/Url'; +import { getFiles } from '../../../api/components'; + + +const X_METRIC = 'violations'; +const Y_METRIC = 'sqale_index'; +const SIZE_METRIC_1 = 'blocker_violations'; +const SIZE_METRIC_2 = 'critical_violations'; +const COMPONENTS_METRICS = [X_METRIC, Y_METRIC, SIZE_METRIC_1, SIZE_METRIC_2]; +const HEIGHT = 360; + + +function formatIssues (d) { + return window.formatMeasure(d, 'SHORT_INT'); +} + +function formatDebt (d) { + return window.formatMeasure(d, 'SHORT_WORK_DUR'); +} + +function getMeasure (component, metric) { + return component.measures[metric] || 0; +} + + +export class IssuesBubbleChart extends React.Component { + constructor () { + super(); + this.state = { loading: true, files: [] }; + } + + componentDidMount () { + this.requestFiles(); + } + + requestFiles () { + return getFiles(this.props.component.key, COMPONENTS_METRICS).then(r => { + let files = r.map(file => { + let measures = {}; + (file.msr || []).forEach(measure => { + measures[measure.key] = measure.val; + }); + return _.extend(file, { measures }); + }); + this.setState({ loading: false, files }); + }); + } + + renderLoading () { + return
    + +
    ; + } + + renderBubbleChart () { + if (this.state.loading) { + return this.renderLoading(); + } + + let items = this.state.files.map(component => { + return { + x: getMeasure(component, X_METRIC), + y: getMeasure(component, Y_METRIC), + size: getMeasure(component, SIZE_METRIC_1) + getMeasure(component, SIZE_METRIC_2), + link: getProjectUrl(component.key) + }; + }); + let xGrid = this.state.files.map(component => component.measures[X_METRIC]); + let tooltips = this.state.files.map(component => { + let inner = [ + component.name, + `Issues: ${formatIssues(getMeasure(component, X_METRIC))}`, + `Technical Debt: ${formatDebt(getMeasure(component, Y_METRIC))}`, + `Blocker & Critical Issues: ${formatIssues(getMeasure(component, SIZE_METRIC_1) + getMeasure(component, SIZE_METRIC_2))}` + ].join('
    '); + return `
    ${inner}
    `; + }); + return ; + } + + render () { + return
    +
    +

    Project Files

    +
      +
    • X: Issues
    • +
    • Y: Technical Debt
    • +
    • Size: Blocker & Critical Issues
    • +
    +
    +
    + {this.renderBubbleChart()} +
    +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/issues/main.js b/server/sonar-web/src/main/js/apps/overview/issues/main.js new file mode 100644 index 00000000000..e55a2f0cf2b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/main.js @@ -0,0 +1,66 @@ +import React from 'react'; + +import IssuesSeverities from './severities'; +import IssuesAssignees from './assignees'; +import IssuesTags from './tags'; +import { IssuesBubbleChart } from './bubble-chart'; +import { IssuesTimeline } from './timeline'; +import { IssuesTreemap } from './treemap'; + +import { getSeverities, getTags, getAssignees } from '../../../api/issues'; + + +export default class OverviewDomain extends React.Component { + constructor () { + super(); + this.state = { severities: [], tags: [], assignees: [] }; + } + + componentDidMount () { + Promise.all([ + this.requestSeverities(), + this.requestTags(), + this.requestAssignees() + ]).then(responses => { + this.setState({ + severities: responses[0], + tags: responses[1], + assignees: responses[2] + }); + }); + } + + requestSeverities () { + return getSeverities({ resolved: 'false', componentUuids: this.props.component.id }); + } + + requestTags () { + return getTags({ resolved: 'false', componentUuids: this.props.component.id }); + } + + requestAssignees () { + return getAssignees({ statuses: 'OPEN,REOPENED', componentUuids: this.props.component.id }); + } + + render () { + return
    + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/issues/severities.js b/server/sonar-web/src/main/js/apps/overview/issues/severities.js new file mode 100644 index 00000000000..be2911b9889 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/severities.js @@ -0,0 +1,35 @@ +import _ from 'underscore'; +import React from 'react'; +import SeverityHelper from '../../../components/shared/severity-helper'; +import { DomainHeader } from '../domain/header'; +import { formatMeasure } from '../formatting'; +import { componentIssuesUrl } from '../../../helpers/Url'; + +export default class extends React.Component { + sortedSeverities () { + return _.sortBy(this.props.severities, s => window.severityComparator(s.val)); + } + + render () { + let rows = this.sortedSeverities().map(s => { + let href = componentIssuesUrl(this.props.component.key, { resolved: 'false', severities: s.val }); + return + + + + + + {formatMeasure(s.count, 'violations')} + + + ; + }); + + return
    + + + {rows} +
    +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/issues/tags.js b/server/sonar-web/src/main/js/apps/overview/issues/tags.js new file mode 100644 index 00000000000..8c29d00f683 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/tags.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { DomainHeader } from '../domain/header'; +import { WordCloud } from '../../../components/charts/word-cloud'; +import { componentIssuesUrl } from '../../../helpers/Url'; + +export default class extends React.Component { + renderWordCloud () { + let tags = this.props.tags.map(tag => { + let link = componentIssuesUrl(this.props.component.key, { resolved: 'false', tags: tag.val }); + let tooltip = `Issues: ${window.formatMeasure(tag.count, 'SHORT_INT')}`; + return { text: tag.val, size: tag.count, link, tooltip }; + }); + return ; + } + + render () { + return
    + +
    + {this.renderWordCloud()} +
    +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/issues/timeline.js b/server/sonar-web/src/main/js/apps/overview/issues/timeline.js new file mode 100644 index 00000000000..5266dad14ae --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/timeline.js @@ -0,0 +1,147 @@ +import _ from 'underscore'; +import moment from 'moment'; +import React from 'react'; + +import { LineChart } from '../../../components/charts/line-chart'; +import { formatMeasure } from '../formatting'; +import { getTimeMachineData } from '../../../api/time-machine'; +import { getEvents } from '../../../api/events'; + + +const ISSUES_METRICS = [ + 'violations', + 'blocker_violations', + 'critical_violations', + 'major_violations', + 'minor_violations', + 'info_violations', + 'confirmed_issues', + 'false_positive_issues', + 'open_issues', + 'reopened_issues' +]; + +const DEBT_METRICS = [ + 'sqale_index', + 'sqale_debt_ratio' +]; + +const HEIGHT = 280; + + +export class IssuesTimeline extends React.Component { + constructor () { + super(); + this.state = { loading: true, currentMetric: ISSUES_METRICS[0] }; + } + + 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 = React.findDOMNode(this.refs.metricSelect).value; + this.setState({ currentMetric: metric }, this.requestTimeMachineData); + } + + renderLoading () { + return
    + +
    ; + } + + renderLineChart () { + if (this.state.loading) { + return this.renderLoading(); + } + + let events = this.prepareEvents(); + + let data = events.map((event, index) => { + return { x: index, y: event.value }; + }); + + let xTicks = events.map(event => event.version.substr(0, 6)); + + let xValues = events.map(event => formatMeasure(event.value, this.state.currentMetric)); + + // TODO use leak period + let backdropConstraints = [ + this.state.events.length - 2, + this.state.events.length - 1 + ]; + + return ; + } + + renderTimelineMetricSelect () { + if (this.state.loading) { + return null; + } + + let issueOptions = ISSUES_METRICS + .map(metric => ); + let debtOptions = DEBT_METRICS + .map(metric => ); + + return ; + } + + render () { + return
    +
    +

    Project History

    + {this.renderTimelineMetricSelect()} +
    +
    + {this.renderLineChart()} +
    +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/issues/treemap.js b/server/sonar-web/src/main/js/apps/overview/issues/treemap.js new file mode 100644 index 00000000000..665406c41d9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/issues/treemap.js @@ -0,0 +1,99 @@ +import _ from 'underscore'; +import React from 'react'; + +import { Treemap } from '../../../components/charts/treemap'; +import { formatMeasure } from '../formatting'; +import { getChildren } from '../../../api/components'; + +const COMPONENTS_METRICS = [ + 'lines', + 'sqale_rating' +]; + +const HEIGHT = 360; + +export class IssuesTreemap extends React.Component { + constructor () { + super(); + this.state = { loading: true, components: [] }; + } + + componentDidMount () { + this.requestComponents(); + } + + requestComponents () { + return getChildren(this.props.component.key, COMPONENTS_METRICS).then(r => { + let components = r.map(component => { + let measures = {}; + component.msr.forEach(measure => { + measures[measure.key] = measure.val; + }); + return _.extend(component, { measures }); + }); + this.setState({ loading: false, components }); + }); + } + + // TODO use css + getRatingColor (rating) { + switch (rating) { + case 1: + return '#00AA00'; + case 2: + return '#80CC00'; + case 3: + return '#FFEE00'; + case 4: + return '#F77700'; + case 5: + return '#EE0000'; + default: + return '#777'; + } + } + + renderLoading () { + return
    + +
    ; + } + + renderTreemap () { + if (this.state.loading) { + return this.renderLoading(); + } + + let items = this.state.components.map(component => { + return { + size: component.measures['lines'], + color: this.getRatingColor(component.measures['sqale_rating']) + }; + }); + let labels = this.state.components.map(component => component.name); + let tooltips = this.state.components.map(component => { + let inner = [ + component.name, + `Lines: ${formatMeasure(component.measures['lines'], 'lines')}`, + `SQALE Rating: ${formatMeasure(component.measures['sqale_rating'], 'sqale_rating')}` + ].join('
    '); + return `
    ${inner}
    `; + }); + return ; + } + + render () { + return
    +
    +

    Project Components

    +
      +
    • Size: Lines
    • +
    • Color: SQALE Rating
    • +
    +
    +
    + {this.renderTreemap()} +
    +
    ; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/main.js b/server/sonar-web/src/main/js/apps/overview/main.js index dc1025a8911..81924a313cc 100644 --- a/server/sonar-web/src/main/js/apps/overview/main.js +++ b/server/sonar-web/src/main/js/apps/overview/main.js @@ -1,102 +1,42 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import moment from 'moment'; import React from 'react'; -import Gate from './gate'; -import Leak from './leak'; -import Nutshell from './nutshell'; +import offset from 'document-offset'; +import GeneralMain from './general/main'; +import IssuesMain from './issues/main'; import Meta from './meta'; -import {getPeriodDate} from './helpers/period-label'; -export default React.createClass({ - getInitialState() { - return { leak: this.props.leak, measures: this.props.measures }; - }, - - componentDidMount() { - if (this._hasWaterLeak()) { - this.requestLeakIssues(); - this.requestLeakDebt(); - } - this.requestNutshellIssues(); - this.requestNutshellDebt(); - }, - - _hasWaterLeak() { - return !!_.findWhere(this.props.component.periods, { index: '3' }); - }, - - _requestIssues(data) { - let url = `${baseUrl}/api/issues/search`; - data.ps = 1; - data.componentUuids = this.props.component.id; - return $.get(url, data); - }, - - requestLeakIssues() { - let createdAfter = moment(getPeriodDate(this.props.component.periods, '3')).format('YYYY-MM-DDTHH:mm:ssZZ'); - this._requestIssues({ resolved: 'false', createdAfter, facets: 'severities,statuses' }).done(r => { - let - severitiesFacet = _.findWhere(r.facets, { property: 'severities' }).values, - statusesFacet = _.findWhere(r.facets, { property: 'statuses' }).values; - - this.setState({ - leak: _.extend({}, this.state.leak, { - newIssues: r.total, - newBlockerIssues: _.findWhere(severitiesFacet, { val: 'BLOCKER' }).count, - newCriticalIssues: _.findWhere(severitiesFacet, { val: 'CRITICAL' }).count, - newOpenIssues: _.findWhere(statusesFacet, { val: 'OPEN' }).count, - newReopenedIssues: _.findWhere(statusesFacet, { val: 'REOPENED' }).count - }) - }); - }); - }, - - requestNutshellIssues() { - this._requestIssues({ resolved: 'false', facets: 'severities,statuses' }).done(r => { - let - severitiesFacet = _.findWhere(r.facets, { property: 'severities' }).values, - statusesFacet = _.findWhere(r.facets, { property: 'statuses' }).values; +export default class Overview extends React.Component { + constructor () { + super(); + let hash = window.location.hash; + this.state = { section: hash.length ? hash.substr(1) : null }; + } - this.setState({ - measures: _.extend({}, this.state.measures, { - issues: r.total, - blockerIssues: _.findWhere(severitiesFacet, { val: 'BLOCKER' }).count, - criticalIssues: _.findWhere(severitiesFacet, { val: 'CRITICAL' }).count, - openIssues: _.findWhere(statusesFacet, { val: 'OPEN' }).count, - reopenedIssues: _.findWhere(statusesFacet, { val: 'REOPENED' }).count - }) - }); - }); - }, + handleRoute (section, el) { + this.setState({ section }, () => this.scrollToEl(el)); + window.location.href = '#' + section; + } - requestLeakDebt() { - let createdAfter = moment(getPeriodDate(this.props.component.periods, '3')).format('YYYY-MM-DDTHH:mm:ssZZ'); - this._requestIssues({ resolved: 'false', createdAfter, facets: 'severities', facetMode: 'debt' }).done(r => { - this.setState({ - leak: _.extend({}, this.state.leak, { newDebt: r.debtTotal }) - }); - }); - }, + scrollToEl (el) { + let top = offset(el).top - el.getBoundingClientRect().height; + window.scrollTo(0, top); + } - requestNutshellDebt() { - this._requestIssues({ resolved: 'false', facets: 'severities', facetMode: 'debt' }).done(r => { - this.setState({ - measures: _.extend({}, this.state.measures, { debt: r.debtTotal }) - }); - }); - }, + render () { + let child; + switch (this.state.section) { + case 'issues': + child = ; + break; + default: + child = null; + } - render() { - return ( -
    -
    - - - -
    - -
    - ); + return
    +
    + + {child} +
    + +
    ; } -}); +} 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 new file mode 100644 index 00000000000..e2c5d200f57 --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/bubble-chart.js @@ -0,0 +1,213 @@ +import $ from 'jquery'; +import d3 from 'd3'; +import React from 'react'; + +export class Bubble extends React.Component { + handleClick () { + if (this.props.link) { + window.location = this.props.link; + } + } + + render () { + let tooltipAttrs = {}; + if (this.props.tooltip) { + tooltipAttrs = { + 'data-toggle': 'tooltip', + 'title': this.props.tooltip + }; + } + return ; + } +} + + +export class BubbleChart extends React.Component { + constructor (props) { + super(); + this.state = { width: props.width, height: props.height }; + } + + componentDidMount () { + if (!this.props.width || !this.props.height) { + this.handleResize(); + window.addEventListener('resize', this.handleResize.bind(this)); + } + this.initTooltips(); + } + + componentDidUpdate () { + this.initTooltips(); + } + + componentWillUnmount () { + if (!this.props.width || !this.props.height) { + window.removeEventListener('resize', this.handleResize.bind(this)); + } + } + + handleResize () { + let boundingClientRect = React.findDOMNode(this).parentNode.getBoundingClientRect(); + let newWidth = this.props.width || boundingClientRect.width; + let newHeight = this.props.height || boundingClientRect.height; + this.setState({ width: newWidth, height: newHeight }); + } + + initTooltips () { + $('[data-toggle="tooltip"]', React.findDOMNode(this)) + .tooltip({ container: 'body', placement: 'bottom', html: true }); + } + + getXRange (xScale, sizeScale, availableWidth) { + var minX = d3.min(this.props.items, d => xScale(d.x) - sizeScale(d.size)), + maxX = d3.max(this.props.items, d => xScale(d.x) + sizeScale(d.size)), + dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0], + dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0; + return [dMinX, availableWidth - dMaxX]; + } + + getYRange (yScale, sizeScale, availableHeight) { + var minY = d3.min(this.props.items, d => yScale(d.y) - sizeScale(d.size)), + maxY = d3.max(this.props.items, d => yScale(d.y) + sizeScale(d.size)), + dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1], + dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0; + return [availableHeight - dMaxY, dMinY]; + } + + renderXGrid (xScale, yScale) { + if (!this.props.displayXGrid) { + return null; + } + + let lines = xScale.ticks().map((tick, index) => { + let x = xScale(tick); + let y1 = yScale.range()[0]; + let y2 = yScale.range()[1]; + + // TODO extract styling + return ; + }); + + return {lines}; + } + + renderYGrid (xScale, yScale) { + if (!this.props.displayYGrid) { + return null; + } + + let lines = yScale.ticks(5).map((tick, index) => { + let y = yScale(tick); + let x1 = xScale.range()[0]; + let x2 = xScale.range()[1]; + + // TODO extract styling + return ; + }); + + return {lines}; + } + + renderXTicks (xScale, yScale) { + if (!this.props.displayXTicks) { + return null; + } + + let ticks = xScale.ticks().map((tick, index) => { + let x = xScale(tick); + let y = yScale.range()[0]; + let text = this.props.formatXTick(tick); + + // TODO extract styling + return {text}; + }); + + return {ticks}; + } + + renderYTicks (xScale, yScale) { + if (!this.props.displayYTicks) { + return null; + } + + let ticks = yScale.ticks(5).map((tick, index) => { + let x = xScale.range()[0]; + let y = yScale(tick); + let text = this.props.formatYTick(tick); + + // TODO extract styling + return {text}; + }); + + return {ticks}; + } + + render () { + if (!this.state.width || !this.state.height) { + return
    ; + } + + 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.scale.linear() + .domain([0, d3.max(this.props.items, d => d.x)]) + .range([0, availableWidth]) + .nice(); + let yScale = d3.scale.linear() + .domain([0, d3.max(this.props.items, d => d.y)]) + .range([availableHeight, 0]) + .nice(); + let sizeScale = d3.scale.linear() + .domain([0, d3.max(this.props.items, d => d.size)]) + .range(this.props.sizeRange); + + xScale.range(this.getXRange(xScale, sizeScale, availableWidth)); + yScale.range(this.getYRange(yScale, sizeScale, availableHeight)); + + let bubbles = this.props.items + .map((item, index) => { + let tooltip = index < this.props.tooltips.length ? this.props.tooltips[index] : null; + return ; + }); + + return + + {this.renderXGrid(xScale, yScale)} + {this.renderXTicks(xScale, yScale)} + {this.renderYGrid(xScale, yScale)} + {this.renderYTicks(xScale, yScale)} + {bubbles} + + ; + } +} + +BubbleChart.defaultProps = { + sizeRange: [5, 45], + displayXGrid: true, + displayYGrid: true, + displayXTicks: true, + displayYTicks: true, + tooltips: [], + padding: [10, 10, 10, 10], + formatXTick: d => d, + formatYTick: d => d +}; + +BubbleChart.propTypes = { + sizeRange: React.PropTypes.arrayOf(React.PropTypes.number), + displayXGrid: React.PropTypes.bool, + displayYGrid: React.PropTypes.bool, + padding: React.PropTypes.arrayOf(React.PropTypes.number), + formatXTick: React.PropTypes.func, + formatYTick: React.PropTypes.func +}; 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 new file mode 100644 index 00000000000..bbb14f3a69d --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/line-chart.js @@ -0,0 +1,156 @@ +import d3 from 'd3'; +import React from 'react'; + +export class LineChart extends React.Component { + constructor (props) { + super(); + this.state = { width: props.width, height: props.height }; + } + + componentDidMount () { + if (!this.props.width || !this.props.height) { + this.handleResize(); + window.addEventListener('resize', this.handleResize.bind(this)); + } + } + + componentWillUnmount () { + if (!this.props.width || !this.props.height) { + window.removeEventListener('resize', this.handleResize.bind(this)); + } + } + + handleResize () { + let boundingClientRect = React.findDOMNode(this).parentNode.getBoundingClientRect(); + let newWidth = this.props.width || boundingClientRect.width; + let newHeight = this.props.height || boundingClientRect.height; + this.setState({ width: newWidth, height: newHeight }); + } + + renderBackdrop (xScale, yScale) { + if (!this.props.displayBackdrop) { + return null; + } + + let area = d3.svg.area() + .x(d => xScale(d.x)) + .y0(yScale.range()[0]) + .y1(d => yScale(d.y)) + .interpolate(this.props.interpolate); + + let data = this.props.data; + if (this.props.backdropConstraints) { + let c = this.props.backdropConstraints; + data = data.filter(d => c[0] <= d.x && d.x <= c[1]); + } + + // TODO extract styling + return ; + } + + renderPoints (xScale, yScale) { + if (!this.props.displayPoints) { + return null; + } + let points = this.props.data.map((point, index) => { + let x = xScale(point.x); + let y = yScale(point.y); + return ; + }); + return {points}; + } + + renderVerticalGrid (xScale, yScale) { + if (!this.props.displayVerticalGrid) { + return null; + } + let lines = this.props.data.map((point, index) => { + let x = xScale(point.x); + let y1 = yScale.range()[0]; + let y2 = yScale(point.y); + return ; + }); + return {lines}; + } + + renderXTicks (xScale, yScale) { + if (!this.props.xTicks.length) { + return null; + } + let ticks = this.props.xTicks.map((tick, index) => { + let point = this.props.data[index]; + let x = xScale(point.x); + let y = yScale.range()[0]; + return {tick}; + }); + return {ticks}; + } + + renderXValues (xScale, yScale) { + if (!this.props.xValues.length) { + return null; + } + let ticks = this.props.xValues.map((value, index) => { + let point = this.props.data[index]; + let x = xScale(point.x); + let y = yScale(point.y); + return {value}; + }); + return {ticks}; + } + + renderLine (xScale, yScale) { + let path = d3.svg.line() + .x(d => xScale(d.x)) + .y(d => yScale(d.y)) + .interpolate(this.props.interpolate); + + return ; + } + + render () { + if (!this.state.width || !this.state.height) { + return
    ; + } + + 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 maxY = d3.max(this.props.data, d => d.y); + let xScale = d3.scale.linear() + .domain(d3.extent(this.props.data, d => d.x)) + .range([0, availableWidth]); + let yScale = d3.scale.linear() + .domain([0, maxY]) + .range([availableHeight, 0]); + + return + + {this.renderVerticalGrid(xScale, yScale, maxY)} + {this.renderBackdrop(xScale, yScale)} + {this.renderLine(xScale, yScale)} + {this.renderPoints(xScale, yScale)} + {this.renderXTicks(xScale, yScale)} + {this.renderXValues(xScale, yScale)} + + ; + } +} + +LineChart.defaultProps = { + displayBackdrop: true, + displayPoints: true, + displayVerticalGrid: true, + xTicks: [], + xValues: [], + padding: [10, 10, 10, 10], + interpolate: 'basis' +}; + +LineChart.propTypes = { + data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + xTicks: React.PropTypes.arrayOf(React.PropTypes.any), + xValues: React.PropTypes.arrayOf(React.PropTypes.any), + padding: React.PropTypes.arrayOf(React.PropTypes.number), + backdropConstraints: React.PropTypes.arrayOf(React.PropTypes.number) +}; diff --git a/server/sonar-web/src/main/js/components/charts/timeline.js b/server/sonar-web/src/main/js/components/charts/timeline.js new file mode 100644 index 00000000000..d200ff154cf --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/timeline.js @@ -0,0 +1,85 @@ +import d3 from 'd3'; +import React from 'react'; + +export class Timeline extends React.Component { + constructor (props) { + super(); + this.state = { width: props.width, height: props.height }; + } + + componentDidMount () { + if (!this.props.width || !this.props.height) { + this.handleResize(); + window.addEventListener('resize', this.handleResize.bind(this)); + } + } + + componentWillUnmount () { + if (!this.props.width || !this.props.height) { + window.removeEventListener('resize', this.handleResize.bind(this)); + } + } + + handleResize () { + let boundingClientRect = React.findDOMNode(this).parentNode.getBoundingClientRect(); + let newWidth = this.props.width || boundingClientRect.width; + let newHeight = this.props.height || boundingClientRect.height; + this.setState({ width: newWidth, height: newHeight }); + } + + renderBackdrop (xScale, yScale, maxY) { + if (!this.props.displayBackdrop) { + return null; + } + + let area = d3.svg.area() + .x(d => xScale(d.date)) + .y0(maxY) + .y1(d => yScale(d.value)) + .interpolate(this.props.interpolate); + + // TODO extract styling + return ; + } + + renderLine (xScale, yScale) { + let path = d3.svg.line() + .x(d => xScale(d.date)) + .y(d => yScale(d.value)) + .interpolate(this.props.interpolate); + + // TODO extract styling + return ; + } + + render () { + if (!this.state.width || !this.state.height) { + return
    ; + } + + let maxY = d3.max(this.props.snapshots, d => d.value); + let xScale = d3.time.scale() + .domain(d3.extent(this.props.snapshots, d => d.date)) + .range([0, this.state.width - this.props.lineWidth]); + let yScale = d3.scale.linear() + .domain([0, maxY]) + .range([this.state.height, 0]); + + return + + {this.renderBackdrop(xScale, yScale, maxY)} + {this.renderLine(xScale, yScale)} + + ; + } +} + +Timeline.defaultProps = { + lineWidth: 2, + displayBackdrop: true, + interpolate: 'basis' +}; + +Timeline.propTypes = { + snapshots: React.PropTypes.arrayOf(React.PropTypes.object).isRequired +}; diff --git a/server/sonar-web/src/main/js/components/charts/treemap.js b/server/sonar-web/src/main/js/components/charts/treemap.js new file mode 100644 index 00000000000..db6ceb8d364 --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/treemap.js @@ -0,0 +1,143 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import d3 from 'd3'; +import React from 'react'; + + +const SIZE_SCALE = d3.scale.linear() + .domain([3, 15]) + .range([11, 18]) + .clamp(true); + + +function mostCommitPrefix (strings) { + var sortedStrings = strings.slice(0).sort(), + firstString = sortedStrings[0], + firstStringLength = firstString.length, + lastString = sortedStrings[sortedStrings.length - 1], + i = 0; + while (i < firstStringLength && firstString.charAt(i) === lastString.charAt(i)) { + i++; + } + var prefix = firstString.substr(0, i), + lastPrefixPart = _.last(prefix.split(/[\s\\\/]/)); + return prefix.substr(0, prefix.length - lastPrefixPart.length); +} + + +export class TreemapRect extends React.Component { + render () { + let tooltipAttrs = {}; + if (this.props.tooltip) { + tooltipAttrs = { + 'data-toggle': 'tooltip', + 'title': this.props.tooltip + }; + } + let cellStyles = { + left: this.props.x, + top: this.props.y, + width: this.props.width, + height: this.props.height, + backgroundColor: this.props.fill, + fontSize: SIZE_SCALE(this.props.width / this.props.label.length), + lineHeight: `${this.props.height}px` + }; + return
    +
    +
    ; + } +} + +TreemapRect.propTypes = { + x: React.PropTypes.number.isRequired, + y: React.PropTypes.number.isRequired, + width: React.PropTypes.number.isRequired, + height: React.PropTypes.number.isRequired, + fill: React.PropTypes.string.isRequired +}; + + +export class Treemap extends React.Component { + constructor (props) { + super(); + this.state = { width: props.width, height: props.height }; + } + + componentDidMount () { + if (!this.props.width || !this.props.height) { + this.handleResize(); + window.addEventListener('resize', this.handleResize.bind(this)); + } + this.initTooltips(); + } + + componentDidUpdate () { + this.initTooltips(); + } + + componentWillUnmount () { + if (!this.props.width || !this.props.height) { + window.removeEventListener('resize', this.handleResize.bind(this)); + } + } + + initTooltips () { + $('[data-toggle="tooltip"]', React.findDOMNode(this)) + .tooltip({ container: 'body', placement: 'top', html: true }); + } + + handleResize () { + let boundingClientRect = React.findDOMNode(this).parentNode.getBoundingClientRect(); + let newWidth = this.props.width || boundingClientRect.width; + let newHeight = this.props.height || boundingClientRect.height; + this.setState({ width: newWidth, height: newHeight }); + } + + render () { + if (!this.state.width || !this.state.height || !this.props.items.length) { + return
     
    ; + } + + 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)) + .size([this.state.width, 360]); + let nodes = treemap + .nodes({ children: this.props.items }) + .filter(d => !d.children); + + let prefix = mostCommitPrefix(this.props.labels), + prefixLength = prefix.length; + + let rectangles = nodes.map((node, index) => { + let label = prefixLength ? `${prefix}
    ${this.props.labels[index].substr(prefixLength)}` : + this.props.labels[index]; + let tooltip = index < this.props.tooltips.length ? this.props.tooltips[index] : null; + return ; + }); + + return
    +
    + {rectangles} +
    +
    ; + } +} + +Treemap.propTypes = { + labels: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + tooltips: React.PropTypes.arrayOf(React.PropTypes.string) +}; diff --git a/server/sonar-web/src/main/js/components/charts/word-cloud.js b/server/sonar-web/src/main/js/components/charts/word-cloud.js new file mode 100644 index 00000000000..e2382d9e035 --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/word-cloud.js @@ -0,0 +1,59 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import React from 'react'; + +export class Word extends React.Component { + render () { + let tooltipAttrs = {}; + if (this.props.tooltip) { + tooltipAttrs = { + 'data-toggle': 'tooltip', + 'title': this.props.tooltip + }; + } + return {this.props.text}; + } +} + + +export class WordCloud extends React.Component { + componentDidMount () { + this.initTooltips(); + } + + componentDidUpdate () { + this.initTooltips(); + } + + initTooltips () { + $('[data-toggle="tooltip"]', React.findDOMNode(this)) + .tooltip({ container: 'body', placement: 'bottom', html: true }); + } + + render () { + let len = this.props.items.length; + let sortedItems = _.sortBy(this.props.items, (item, idx) => { + let index = len - idx; + return (index % 2) * (len - index) + index / 2; + }); + + let sizeScale = d3.scale.linear() + .domain([0, d3.max(this.props.items, d => d.size)]) + .range(this.props.sizeRange); + let words = sortedItems + .map((item, index) => ); + return
    {words}
    ; + } +} + +WordCloud.defaultProps = { + sizeRange: [10, 24] +}; + +WordCloud.propTypes = { + sizeRange: React.PropTypes.arrayOf(React.PropTypes.number) +}; diff --git a/server/sonar-web/src/main/js/components/shared/assignee-helper.js b/server/sonar-web/src/main/js/components/shared/assignee-helper.js new file mode 100644 index 00000000000..6f3aedd9dba --- /dev/null +++ b/server/sonar-web/src/main/js/components/shared/assignee-helper.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Avatar from './avatar'; + +export default class Assignee extends React.Component { + render () { + let avatar = this.props.user ? + : null; + let name = this.props.user ? this.props.user.name : window.t('unassigned'); + return {avatar}{name}; + } +} diff --git a/server/sonar-web/src/main/js/components/shared/severity-helper.js b/server/sonar-web/src/main/js/components/shared/severity-helper.js index a0e931aca2f..5aeec6e3b68 100644 --- a/server/sonar-web/src/main/js/components/shared/severity-helper.js +++ b/server/sonar-web/src/main/js/components/shared/severity-helper.js @@ -6,12 +6,11 @@ export default React.createClass({ if (!this.props.severity) { return null; } - return ( - - -   - {window.t('severity', this.props.severity)} - - ); + return + + + + {window.t('severity', this.props.severity)} + ; } }); diff --git a/server/sonar-web/src/main/js/helpers/Url.js b/server/sonar-web/src/main/js/helpers/Url.js index 6a57b3537f7..f369bb79b67 100644 --- a/server/sonar-web/src/main/js/helpers/Url.js +++ b/server/sonar-web/src/main/js/helpers/Url.js @@ -1,6 +1,13 @@ -export function getProjectUrl(project) { +export function getProjectUrl (project) { if (typeof project !== 'string') { throw new TypeError('Project ID or KEY should be passed'); } - return `${window.baseUrl}/overview?id=${encodeURIComponent(project)}`; + return `${window.baseUrl}/dashboard?id=${encodeURIComponent(project)}`; +} + +export function componentIssuesUrl (componentKey, query) { + let serializedQuery = Object.keys(query).map(criterion => { + return `${encodeURIComponent(criterion)}=${encodeURIComponent(query[criterion])}`; + }).join('|'); + return window.baseUrl + '/component_issues?id=' + encodeURIComponent(componentKey) + '#' + serializedQuery; } diff --git a/server/sonar-web/src/main/less/components/columns.less b/server/sonar-web/src/main/less/components/columns.less index 106c3a5c871..1e59a6f13ad 100644 --- a/server/sonar-web/src/main/less/components/columns.less +++ b/server/sonar-web/src/main/less/components/columns.less @@ -31,3 +31,20 @@ padding: 0 10px; .box-sizing(border-box); } + + +.flex-columns { + display: flex; +} + +.flex-column + .flex-column { + margin-left: 20px; +} + +.flex-column-half { + width: 50%; +} + +.flex-column-third { + width: ~"calc(100% / 3)"; +} diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less index 3226307b9ae..6a2fb4ae9e8 100644 --- a/server/sonar-web/src/main/less/components/graphics.less +++ b/server/sonar-web/src/main/less/components/graphics.less @@ -1,5 +1,6 @@ @import (reference) "../variables"; @import (reference) "../mixins"; +@import (reference) "../init/links"; .sonar-d3 { @@ -233,3 +234,15 @@ text.max-results-reached-message { & > .icon-chevron-right { margin-right: 10px; } } + +.word-cloud { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + align-items: center; + + a { + padding: 5px; + .link-no-underline; + } +} diff --git a/server/sonar-web/src/main/less/components/tooltips.less b/server/sonar-web/src/main/less/components/tooltips.less index bf9cbb70548..5cda5316155 100644 --- a/server/sonar-web/src/main/less/components/tooltips.less +++ b/server/sonar-web/src/main/less/components/tooltips.less @@ -45,6 +45,7 @@ text-decoration: none; background-color: @background; border-radius: 4px; + letter-spacing: 0.04em; } .tooltip-arrow { diff --git a/server/sonar-web/src/main/less/pages/overview.less b/server/sonar-web/src/main/less/pages/overview.less index da3055a4485..4e0edc3fcba 100644 --- a/server/sonar-web/src/main/less/pages/overview.less +++ b/server/sonar-web/src/main/less/pages/overview.less @@ -1,6 +1,7 @@ @import (reference) "../variables"; @import (reference) "../mixins"; @import (reference) "../init/type"; +@import (reference) "../init/links"; .overview { display: flex; @@ -30,11 +31,17 @@ font-weight: 300; } -.overview-gate-box-error { background-color: @red; } +.overview-gate-box-error { + background-color: @red; +} -.overview-gate-box-warn { background-color: @orange; } +.overview-gate-box-warn { + background-color: @orange; +} -.overview-gate-box-ok { background-color: @green; } +.overview-gate-box-ok { + background-color: @green; +} .overview-gate-conditions { line-height: 70px; @@ -88,6 +95,7 @@ } .overview-title { + margin-bottom: 20px; font-size: 18px; font-weight: 400; @@ -101,17 +109,17 @@ } } -.overview-title + .overview-cards:not(:empty) { - margin-top: 20px; -} - .overview-leak-period { margin-left: 10px; font-size: 14px; } .overview-nutshell { - padding: 50px 30px; + padding: 50px 30px 0; + + .overview-card { + padding-bottom: 25px; + } } .overview-cards { @@ -154,6 +162,14 @@ } } +.overview-card-section { + cursor: pointer; + + &:hover, &.active { + border-bottom: 4px solid #2c3946; + } +} + .overview-measure { font-size: 28px; } @@ -193,3 +209,116 @@ .text-ellipsis; } } + +.overview-domain-dark { + background-color: #2c3946; + color: mix(#fff, #2c3946, 75%); + + a { + color: @blue; + border-bottom-color: @darkBlue; + + &:hover, &:focus { + border-bottom-color: @blue; + } + } + + .overview-title { + color: mix(#fff, #2c3946, 75%); + } + + table.data.zebra > tbody > tr:nth-child(odd) { + background-color: mix(#fff, #2c3946, 5%);; + } +} + +.overview-domain-section { + padding: 50px 30px; +} + +.overview-domain-header { + display: flex; + align-items: baseline; + margin-bottom: 20px;; + padding: 50px 30px 0; + + .overview-title { + flex: 1; + margin: 0; + padding: 0; + } +} + +.overview-timeline { + position: relative; + + .line-chart { + + } + + .line-chart-grid { + shape-rendering: crispedges; + stroke: #384653; + } + + .line-chart-path { + fill: none; + stroke-width: 2; + stroke: @blue; + } + + .line-chart-point { + fill: @blue; + stroke: none; + } + + .line-chart-tick { + fill: mix(#fff, #2c3946); + font-size: 11px; + text-anchor: middle; + } +} + +.overview-timeline-select { + height: @formControlHeight; + border: 1px solid mix(#fff, #2c3946); + background-color: transparent; + color: mix(#fff, #2c3946); +} + +.overview-bubble-chart { + .bubble-chart-tick { + fill: mix(#fff, #2c3946); + font-size: 11px; + text-anchor: middle; + } + + .bubble-chart-tick-y { + text-anchor: end; + } + + .bubble-chart-bubble { + stroke: @blue; + fill: @blue; + fill-opacity: 0.2; + transition: fill-opacity 0.2s ease; + + &:hover { + fill-opacity: 0.5; + } + } +} + +.overview-treemap { + .overview-domain-header { + padding-left: 0; + padding-right: 0; + } +} + +.overview-chart-placeholder { + display: flex; + justify-content: center; + align-items: center; + align-content: center; +} -- 2.39.5