]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-6946 Add an ability to drilldown on the overview treemap
authorStas Vilchik <vilchiks@gmail.com>
Mon, 30 Nov 2015 14:38:42 +0000 (15:38 +0100)
committerStas Vilchik <vilchiks@gmail.com>
Mon, 30 Nov 2015 14:38:52 +0000 (15:38 +0100)
server/sonar-web/src/main/js/apps/overview/components/domain-treemap.js
server/sonar-web/src/main/js/apps/overview/domains/coverage-domain.js
server/sonar-web/src/main/js/apps/overview/domains/duplications-domain.js
server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/treemap.js
server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js
server/sonar-web/src/main/js/helpers/constants.js
server/sonar-web/src/main/webapp/WEB-INF/app/views/dashboard/no_dashboard.html.erb

index 0014cd54e9fa5e141bc888ce2fb1991a291d7772..29d08065f0bba350d877ceacd18687da9f619ac7 100644 (file)
@@ -4,6 +4,7 @@ import React from 'react';
 import { Treemap } from '../../../components/charts/treemap';
 import { getChildren } from '../../../api/components';
 import { formatMeasure } from '../../../helpers/measures';
+import { getComponentUrl } from '../../../helpers/urls';
 
 
 const HEIGHT = 302;
@@ -16,17 +17,18 @@ export class DomainTreemap extends React.Component {
       loading: true,
       files: [],
       sizeMetric: this.getMetricObject(props.metrics, props.sizeMetric),
-      colorMetric: props.colorMetric ? this.getMetricObject(props.metrics, props.colorMetric) : null
+      colorMetric: props.colorMetric ? this.getMetricObject(props.metrics, props.colorMetric) : null,
+      breadcrumbs: []
     };
   }
 
   componentDidMount () {
-    this.requestComponents();
+    this.requestComponents(this.props.component.key);
   }
 
-  requestComponents () {
+  requestComponents (componentKey) {
     let metrics = [this.props.sizeMetric, this.props.colorMetric];
-    return getChildren(this.props.component.key, metrics).then(r => {
+    return getChildren(componentKey, metrics).then(r => {
       let components = r.map(component => {
         let measures = {};
         (component.msr || []).forEach(measure => {
@@ -45,7 +47,8 @@ export class DomainTreemap extends React.Component {
   getTooltip (component) {
     let inner = [
       component.name,
-      `${this.state.sizeMetric.name}: ${formatMeasure(component.measures[this.props.sizeMetric], this.state.sizeMetric.type)}`
+      `${this.state.sizeMetric.name}:
+      ${formatMeasure(component.measures[this.props.sizeMetric], this.state.sizeMetric.type)}`
     ];
     if (this.state.colorMetric) {
       let measure = component.measures[this.props.colorMetric],
@@ -56,15 +59,31 @@ export class DomainTreemap extends React.Component {
     return `<div class="text-left">${inner}</div>`;
   }
 
-  renderLoading () {
-    return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}>
-      <i className="spinner"/>
-    </div>;
+  handleRectangleClick (node) {
+    this.requestComponents(node.key).then(() => {
+      let nextBreadcrumbs = [...this.state.breadcrumbs];
+      let index = _.findIndex(this.state.breadcrumbs, b => b.key === node.key);
+      if (index !== -1) {
+        nextBreadcrumbs = nextBreadcrumbs.slice(0, index);
+      }
+      nextBreadcrumbs = [...nextBreadcrumbs, {
+        key: node.key,
+        name: node.name,
+        qualifier: node.qualifier
+      }];
+      this.setState({ breadcrumbs: nextBreadcrumbs });
+    });
+  }
+
+  handleReset() {
+    this.requestComponents(this.props.component.key).then(() => {
+      this.setState({ breadcrumbs: [] });
+    });
   }
 
-  renderWhenNoData () {
+  renderLoading () {
     return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}>
-      {window.t('no_data')}
+      <i className="spinner"/>
     </div>;
   }
 
@@ -73,21 +92,30 @@ export class DomainTreemap extends React.Component {
       return this.renderLoading();
     }
 
-    if (!this.state.components.length) {
-      return this.renderWhenNoData();
-    }
-
     // TODO filter out zero sized components
     let items = this.state.components.map(component => {
       let colorMeasure = this.props.colorMetric ? component.measures[this.props.colorMetric] : null;
       return {
+        key: component.key,
+        name: component.name,
+        qualifier: component.qualifier,
         size: component.measures[this.props.sizeMetric],
         color: colorMeasure != null ? this.props.scale(colorMeasure) : '#777',
         tooltip: this.getTooltip(component),
-        label: component.name
+        label: component.name,
+        link: getComponentUrl(component.key)
       };
     });
-    return <Treemap items={items} height={HEIGHT}/>;
+
+    const canBeClicked = node => node.qualifier !== 'FIL' && node.qualifier !== 'UTS';
+
+    return <Treemap
+        items={items}
+        breadcrumbs={this.state.breadcrumbs}
+        height={HEIGHT}
+        canBeClicked={canBeClicked}
+        onRectangleClick={this.handleRectangleClick.bind(this)}
+        onReset={this.handleReset.bind(this)}/>;
   }
 
   render () {
index 6b0cbc391bc90717b1483731b4029645ae731211..d79f7846f51b078382f20b41b69ed78bc17413db 100644 (file)
@@ -10,7 +10,7 @@ import { getPeriodLabel, getPeriodDate } from './../helpers/periods';
 import { TooltipsMixin } from '../../../components/mixins/tooltips-mixin';
 import { filterMetrics, filterMetricsForDomains } from '../helpers/metrics';
 import { DomainLeakTitle } from '../main/components';
-import { CHART_COLORS_RANGE_PERCENT } from '../../../helpers/constants';
+import { CHART_REVERSED_COLORS_RANGE_PERCENT } from '../../../helpers/constants';
 import { CoverageMeasuresList } from '../components/coverage-measures-list';
 
 
@@ -83,8 +83,8 @@ export const CoverageMain = React.createClass({
     }
 
     let treemapScale = d3.scale.linear()
-        .domain([0, 100])
-        .range(CHART_COLORS_RANGE_PERCENT);
+        .domain([0, 25, 50, 75, 100])
+        .range(CHART_REVERSED_COLORS_RANGE_PERCENT);
 
     let coverageMetric = this.state.coverageMetricPrefix + 'coverage',
         uncoveredLinesMetric = this.state.coverageMetricPrefix + 'uncovered_lines';
index 946b6f1663b32c57a203e423387c9a48e242efd2..fe4cae3afac047ea70bb98dc61cf49844b639d87 100644 (file)
@@ -82,7 +82,7 @@ export const DuplicationsMain = React.createClass({
       return this.renderLoading();
     }
     let treemapScale = d3.scale.linear()
-        .domain([0, 100])
+        .domain([0, 25, 50, 75, 100])
         .range(CHART_COLORS_RANGE_PERCENT);
     return <div className="overview-detailed-page">
       <div className="overview-cards-list">
diff --git a/server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js b/server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js
new file mode 100644 (file)
index 0000000..bbd6b2b
--- /dev/null
@@ -0,0 +1,46 @@
+import React from 'react';
+
+import QualifierIcon from '../shared/qualifier-icon';
+
+
+export const TreemapBreadcrumbs = React.createClass({
+  propTypes: {
+    breadcrumbs: React.PropTypes.arrayOf(React.PropTypes.shape({
+      key: React.PropTypes.string.isRequired,
+      name: React.PropTypes.string.isRequired,
+      qualifier: React.PropTypes.string.isRequired
+    }).isRequired).isRequired
+  },
+
+  handleItemClick(item, e) {
+    e.preventDefault();
+    this.props.onRectangleClick(item);
+  },
+
+  handleReset(e) {
+    e.preventDefault();
+    this.props.onReset();
+  },
+
+  renderHome() {
+    return <span className="treemap-breadcrumbs-item">
+      <a onClick={this.handleReset} className="icon-home" href="#"/>
+    </span>;
+  },
+
+  renderBreadcrumbsItems(b) {
+    return <span key={b.key} className="treemap-breadcrumbs-item" title={b.name}>
+      <i className="icon-chevron-right"/>
+      <QualifierIcon qualifier={b.qualifier}/>
+      <a onClick={this.handleItemClick.bind(this, b)} href="#">{b.name}</a>
+    </span>;
+  },
+
+  render() {
+    let breadcrumbs = this.props.breadcrumbs.map(this.renderBreadcrumbsItems);
+    return <div className="treemap-breadcrumbs">
+      {this.props.breadcrumbs.length ? this.renderHome() : null}
+      {breadcrumbs}
+    </div>;
+  }
+});
index de56351ff7e92e54a144b228a8dd431bf38455d0..fe036d3fb9dfd0e2a5bb1393b5daa86d7d8a2fbc 100644 (file)
@@ -2,6 +2,7 @@ import _ from 'underscore';
 import d3 from 'd3';
 import React from 'react';
 
+import { TreemapBreadcrumbs } from './treemap-breadcrumbs';
 import { ResizeMixin } from './../mixins/resize-mixin';
 import { TooltipsMixin } from './../mixins/tooltips-mixin';
 
@@ -35,7 +36,19 @@ export const TreemapRect = React.createClass({
     height: React.PropTypes.number.isRequired,
     fill: React.PropTypes.string.isRequired,
     label: React.PropTypes.string.isRequired,
-    prefix: React.PropTypes.string
+    prefix: React.PropTypes.string,
+    onClick: React.PropTypes.func
+  },
+
+  renderLink() {
+    if (!this.props.link) {
+      return null;
+    }
+
+    return <a onClick={e => e.stopPropagation()}
+              className="treemap-link"
+              href={this.props.link}
+              style={{ fontSize: 12 }}><i className="icon-link"/></a>;
   },
 
   render () {
@@ -53,12 +66,14 @@ export const TreemapRect = React.createClass({
       height: this.props.height,
       backgroundColor: this.props.fill,
       fontSize: SIZE_SCALE(this.props.width / this.props.label.length),
-      lineHeight: `${this.props.height}px`
+      lineHeight: `${this.props.height}px`,
+      cursor: typeof this.props.onClick === 'function' ? 'pointer' : 'default'
     };
     let isTextVisible = this.props.width >= 40 && this.props.height >= 40;
-    return <div className="treemap-cell" {...tooltipAttrs} style={cellStyles}>
+    return <div className="treemap-cell" {...tooltipAttrs} style={cellStyles} onClick={this.props.onClick}>
       <div className="treemap-inner" dangerouslySetInnerHTML={{ __html: this.props.label }}
            style={{ maxWidth: this.props.width, visibility: isTextVisible ? 'visible': 'hidden' }}/>
+      {this.renderLink()}
     </div>;
   }
 });
@@ -69,18 +84,32 @@ export const Treemap = React.createClass({
 
   propTypes: {
     items: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
-    height: React.PropTypes.number
+    height: React.PropTypes.number,
+    onRectangleClick: React.PropTypes.func
   },
 
   getInitialState() {
     return { width: this.props.width, height: this.props.height };
   },
 
+  renderWhenNoData () {
+    return <div className="sonar-d3">
+      <div className="treemap-container" style={{ width: this.state.width, height: this.state.height }}>
+        {window.t('no_data')}
+      </div>
+      <TreemapBreadcrumbs {...this.props}/>
+    </div>
+  },
+
   render () {
-    if (!this.state.width || !this.state.height || !this.props.items.length) {
+    if (!this.state.width || !this.state.height) {
       return <div>&nbsp;</div>;
     }
 
+    if (!this.props.items.length) {
+      return this.renderWhenNoData();
+    }
+
     let treemap = d3.layout.treemap()
                     .round(true)
                     .value(d => d.size)
@@ -96,6 +125,7 @@ export const Treemap = React.createClass({
 
     let rectangles = nodes.map((node, index) => {
       let label = prefixLength ? `${prefix}<br>${node.label.substr(prefixLength)}` : node.label;
+      const onClick = this.props.canBeClicked(node) ? () => this.props.onRectangleClick(node) : null;
       return <TreemapRect key={index}
                           x={node.x}
                           y={node.y}
@@ -104,13 +134,16 @@ export const Treemap = React.createClass({
                           fill={node.color}
                           label={label}
                           prefix={prefix}
-                          tooltip={node.tooltip}/>;
+                          tooltip={node.tooltip}
+                          link={node.link}
+                          onClick={onClick}/>;
     });
 
     return <div className="sonar-d3">
       <div className="treemap-container" style={{ width: this.state.width, height: this.state.height }}>
         {rectangles}
       </div>
+      <TreemapBreadcrumbs {...this.props}/>
     </div>;
   }
 });
index 240edee02c5c8eb5770fac4bce52b90848a9ecb8..af0c2be023e67e2b104539f031d2ba5bd8e874d1 100644 (file)
@@ -7,6 +7,10 @@ export const TooltipsMixin = {
     this.initTooltips();
   },
 
+  componentWillUpdate() {
+    this.destroyTooltips();
+  },
+
   componentDidUpdate () {
     this.initTooltips();
   },
@@ -16,5 +20,12 @@ export const TooltipsMixin = {
       $('[data-toggle="tooltip"]', ReactDOM.findDOMNode(this))
           .tooltip({ container: 'body', placement: 'bottom', html: true });
     }
+  },
+
+  destroyTooltips () {
+    if ($.fn.tooltip) {
+      $('[data-toggle="tooltip"]', ReactDOM.findDOMNode(this))
+          .tooltip('destroy');
+    }
   }
 };
index d1ba85c5395c0400cb0e6138857f21a7a9e61652..80653c27a6868bfdb148a4064ec5b98c466c7943 100644 (file)
@@ -2,3 +2,4 @@ export const SEVERITIES = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO'];
 export const STATUSES = ['OPEN', 'REOPENED', 'CONFIRMED', 'RESOLVED', 'CLOSED'];
 
 export const CHART_COLORS_RANGE_PERCENT = ['#00aa00', '#80cc00', '#ffee00', '#f77700', '#ee0000'];
+export const CHART_REVERSED_COLORS_RANGE_PERCENT = ['#ee0000', '#f77700', '#ffee00', '#80cc00', '#00aa00'];
index 72416ed82eb84fbd1629caffa5d6a1522c968dce..74d50a2f7a067cba3d889b1027a0062cc5941106 100644 (file)
@@ -6,6 +6,7 @@
   <script>
     (function () {
       jQuery('.navbar-context').remove();
+      jQuery('#sidebar').remove();
       jQuery('.page-wrapper-context').addClass('page-wrapper-global').removeClass('page-wrapper-context');
       window.sonarqube.el = '#source-viewer';
       window.sonarqube.file = {