]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9608 SONAR-9636 Add treemap view to the components measures page
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 3 Aug 2017 13:13:22 +0000 (15:13 +0200)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Mon, 14 Aug 2017 09:44:44 +0000 (11:44 +0200)
19 files changed:
server/sonar-web/src/main/js/apps/component-measures/components/App.js
server/sonar-web/src/main/js/apps/component-measures/components/AppContainer.js
server/sonar-web/src/main/js/apps/component-measures/components/Breadcrumb.js
server/sonar-web/src/main/js/apps/component-measures/components/Breadcrumbs.js
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.js
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContentContainer.js
server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentCell.js
server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsList.js
server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsListRow.js
server/sonar-web/src/main/js/apps/component-measures/components/drilldown/FilesView.js
server/sonar-web/src/main/js/apps/component-measures/components/drilldown/TreeMapView.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.js
server/sonar-web/src/main/js/apps/component-measures/utils.js
server/sonar-web/src/main/js/components/charts/TreeMap.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/TreeMapRect.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/__tests__/TreeMap-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/__tests__/treemap-test.js [deleted file]
server/sonar-web/src/main/js/components/measure/utils.js
server/sonar-web/src/main/less/components/graphics.less

index e742c486be052fd1476e8a8d67083e36009010d2..773235ee8ca2dbf74e04290fc56f2ee2a98b356f 100644 (file)
@@ -35,8 +35,8 @@ type Props = {|
   currentUser: { isLoggedIn: boolean },
   location: { pathname: string, query: RawQuery },
   fetchMeasures: (
-    Component,
-    Array<string>
+    component: string,
+    metricsKey: Array<string>
   ) => Promise<{ component: Component, measures: Array<MeasureEnhanced>, leakPeriod: ?Period }>,
   fetchMetrics: () => void,
   metrics: { [string]: Metric },
index 653380d1ad70aa0f1f8b45334bd589c91b6433bd..7ed37803274972d70e3b870c1b35bb3310723cc6 100644 (file)
@@ -26,7 +26,6 @@ import {
   getComponent,
   getCurrentUser,
   getMetrics,
-  getMetricByKey,
   getMetricsKey
 } from '../../../store/rootReducer';
 import { fetchMetrics } from '../../../store/rootActions';
@@ -57,17 +56,17 @@ const banQualityGate = (component: Component): Array<Measure> => {
   return newMeasures;
 };
 
-const fetchMeasures = (component: string, metrics: Array<string>) => (
+const fetchMeasures = (component: string, metricsKey: Array<string>) => (
   dispatch,
   getState
 ): Promise<{ component: Component, measures: Array<MeasureEnhanced>, leakPeriod: ?Period }> => {
-  if (metrics.length <= 0) {
+  if (metricsKey.length <= 0) {
     return Promise.resolve({ component: {}, measures: [], leakPeriod: null });
   }
 
-  return getMeasuresAndMeta(component, metrics, { additionalFields: 'periods' }).then(r => {
+  return getMeasuresAndMeta(component, metricsKey, { additionalFields: 'periods' }).then(r => {
     const measures: Array<MeasureEnhanced> = banQualityGate(r.component).map(measure =>
-      enhanceMeasure(measure, getMetricByKey(getState(), measure.metric))
+      enhanceMeasure(measure, getMetrics(getState()))
     );
 
     const newBugs = measures.find(measure => measure.metric.key === 'new_bugs');
index 2be60f7c9184e20eeb35931b38dfdb8eab879b42..99882ca4a7a83a20042047d0e74359dd9095cd96 100644 (file)
@@ -27,7 +27,7 @@ type Props = {
   canBrowse: boolean,
   component: Component,
   isLast: boolean,
-  handleSelect: Component => void
+  handleSelect: string => void
 };
 
 export default class Breadcrumb extends React.PureComponent {
@@ -36,7 +36,7 @@ export default class Breadcrumb extends React.PureComponent {
   handleClick = (e: Event & { target: HTMLElement }) => {
     e.preventDefault();
     e.target.blur();
-    this.props.handleSelect(this.props.component);
+    this.props.handleSelect(this.props.component.key);
   };
 
   render() {
index 78a035729e8f6f2b11af4e9d24ea1fab47747e7b..a9b9c3ecbcfea4f46542175a58a6f3c0de36d6d6 100644 (file)
@@ -26,7 +26,7 @@ import type { Component } from '../types';
 type Props = {|
   className?: string,
   component: Component,
-  handleSelect: Component => void,
+  handleSelect: string => void,
   rootComponent: Component
 |};
 
index cf99ff493114516d87051c9ba42e5dbcf4049e5f..3f473359707969ab19172df36a72c167761bbd98 100644 (file)
@@ -28,6 +28,7 @@ import MeasureViewSelect from './MeasureViewSelect';
 import MetricNotFound from './MetricNotFound';
 import PageActions from './PageActions';
 import SourceViewer from '../../../components/SourceViewer/SourceViewer';
+import TreeMapView from './drilldown/TreeMapView';
 import { getComponentTree } from '../../../api/components';
 import { complementary } from '../config/complementary';
 import { enhanceComponent, isFileType } from '../utils';
@@ -36,7 +37,7 @@ import type { Component, ComponentEnhanced, Paging, Period } from '../types';
 import type { MeasureEnhanced } from '../../../components/measure/types';
 import type { Metric } from '../../../store/metrics/actions';
 
-type Props = {
+type Props = {|
   className?: string,
   component: Component,
   currentUser: { isLoggedIn: boolean },
@@ -48,10 +49,10 @@ type Props = {
   rootComponent: Component,
   secondaryMeasure: ?MeasureEnhanced,
   updateLoading: ({ [string]: boolean }) => void,
-  updateSelected: Component => void,
+  updateSelected: string => void,
   updateView: string => void,
   view: string
-};
+|};
 
 type State = {
   components: Array<ComponentEnhanced>,
@@ -83,42 +84,42 @@ export default class MeasureContent extends React.PureComponent {
     this.mounted = false;
   }
 
-  getComponentRequestParams = (metric: Metric, options: Object = {}) => {
-    const metricKeys = [metric.key, ...(complementary[metric.key] || [])];
-    let opts: Object = {
-      asc: metric.direction === 1,
-      ps: 100,
-      metricSortFilter: 'withMeasuresOnly',
-      metricSort: metric.key
-    };
-    if (isDiffMetric(metric.key)) {
-      opts = {
-        ...opts,
-        s: 'metricPeriod,name',
-        metricPeriodSort: 1
-      };
+  getComponentRequestParams = (view: string, metric: Metric, options: Object = {}) => {
+    const strategy = view === 'list' ? 'leaves' : 'children';
+    const metricKeys = [metric.key];
+    const opts: Object = { metricSortFilter: 'withMeasuresOnly' };
+    if (view === 'treemap') {
+      metricKeys.push('ncloc');
+      opts.asc = false;
+      opts.metricSort = 'ncloc';
+      opts.s = 'metric';
     } else {
-      opts = {
-        ...opts,
-        s: 'metric,name'
-      };
+      metricKeys.push(...(complementary[metric.key] || []));
+      opts.asc = metric.direction === 1;
+      opts.ps = 100;
+      opts.metricSort = metric.key;
+      if (isDiffMetric(metric.key)) {
+        opts.s = 'metricPeriod,name';
+        opts.metricPeriodSort = 1;
+      } else {
+        opts.s = 'metric,name';
+      }
     }
-    return { metricKeys, opts: { ...opts, ...options } };
+    return { metricKeys, opts: { ...opts, ...options }, strategy };
   };
 
-  fetchComponents = ({ component, metric, view }: Props) => {
+  fetchComponents = ({ component, metric, metrics, view }: Props) => {
     if (isFileType(component)) {
       return this.setState({ components: [], metric: null, paging: null });
     }
 
-    const strategy = view === 'list' ? 'leaves' : 'children';
-    const { metricKeys, opts } = this.getComponentRequestParams(metric);
+    const { metricKeys, opts, strategy } = this.getComponentRequestParams(view, metric);
     this.props.updateLoading({ components: true });
     getComponentTree(strategy, component.key, metricKeys, opts).then(
       r => {
         if (this.mounted) {
           this.setState({
-            components: r.components.map(component => enhanceComponent(component, metric)),
+            components: r.components.map(component => enhanceComponent(component, metric, metrics)),
             metric,
             paging: r.paging
           });
@@ -130,13 +131,12 @@ export default class MeasureContent extends React.PureComponent {
   };
 
   fetchMoreComponents = () => {
-    const { component, metric, view } = this.props;
+    const { component, metric, metrics, view } = this.props;
     const { paging } = this.state;
     if (!paging) {
       return;
     }
-    const strategy = view === 'list' ? 'leaves' : 'children';
-    const { metricKeys, opts } = this.getComponentRequestParams(metric, {
+    const { metricKeys, opts, strategy } = this.getComponentRequestParams(view, metric, {
       p: paging.pageIndex + 1
     });
     this.props.updateLoading({ components: true });
@@ -146,7 +146,7 @@ export default class MeasureContent extends React.PureComponent {
           this.setState(state => ({
             components: [
               ...state.components,
-              ...r.components.map(component => enhanceComponent(component, metric))
+              ...r.components.map(component => enhanceComponent(component, metric, metrics))
             ],
             metric,
             paging: r.paging
@@ -202,11 +202,22 @@ export default class MeasureContent extends React.PureComponent {
         />
       );
     }
+
+    if (view === 'treemap') {
+      return (
+        <TreeMapView
+          components={this.state.components}
+          handleSelect={this.props.updateSelected}
+          metric={metric}
+        />
+      );
+    }
   }
 
   render() {
     const { component, currentUser, measure, metric, rootComponent, view } = this.props;
     const isLoggedIn = currentUser && currentUser.isLoggedIn;
+    const isFile = isFileType(component);
     return (
       <div className={this.props.className}>
         <div className="layout-page-header-panel layout-page-main-header issues-main-header">
@@ -225,16 +236,17 @@ export default class MeasureContent extends React.PureComponent {
                   component={component.key}
                   className="measure-favorite spacer-right"
                 />}
-              <MeasureViewSelect
-                className="measure-view-select"
-                metric={metric}
-                handleViewChange={this.props.updateView}
-                view={view}
-              />
+              {!isFile &&
+                <MeasureViewSelect
+                  className="measure-view-select"
+                  metric={metric}
+                  handleViewChange={this.props.updateView}
+                  view={view}
+                />}
               <PageActions
                 current={this.state.components.length}
                 loading={this.props.loading}
-                isFile={isFileType(component)}
+                isFile={isFile}
                 paging={this.state.paging}
                 view={view}
               />
index 747a7af5c33c44ca7e0579c9f937698b93368365..08378cccae7edbd019f7b851bc36f04bfae9590a 100644 (file)
@@ -29,8 +29,8 @@ type Props = {
   currentUser: { isLoggedIn: boolean },
   rootComponent: Component,
   fetchMeasures: (
-    Component,
-    Array<string>
+    component: string,
+    metricsKey: Array<string>
   ) => Promise<{ component: Component, measures: Array<MeasureEnhanced> }>,
   leakPeriod?: Period,
   metric: Metric,
@@ -114,9 +114,9 @@ export default class MeasureContentContainer extends React.PureComponent {
     }
   };
 
-  updateSelected = (component: Component) =>
+  updateSelected = (component: string) =>
     this.props.updateQuery({
-      selected: component.key !== this.props.rootComponent.key ? component.key : null
+      selected: component !== this.props.rootComponent.key ? component : null
     });
 
   updateView = (view: string) => this.props.updateQuery({ view });
index 8d723304ce81cf6f2dc2a4d7e3cd5a388c666ccd..a0294788571f97816e8863e57850b325b5e964a5 100644 (file)
@@ -28,7 +28,7 @@ import type { Component } from '../../types';
 type Props = {
   component: Component,
   isSelected: boolean,
-  onClick: Component => void
+  onClick: string => void
 };
 
 export default class ComponentCell extends React.PureComponent {
@@ -40,7 +40,7 @@ export default class ComponentCell extends React.PureComponent {
 
     if (isLeftClickEvent && !isModifiedEvent) {
       e.preventDefault();
-      this.props.onClick();
+      this.props.onClick(this.props.component.key);
     }
   };
 
index ba8d42d84c9ae787d7ae0bd4c16494cf64659333..cdf62ecea70f94f0f667c79cad8f62429214da80 100644 (file)
@@ -26,13 +26,13 @@ import { getLocalizedMetricName } from '../../../../helpers/l10n';
 import type { Component } from '../../types';
 import type { Metric } from '../../../../store/metrics/actions';
 
-type Props = {
+type Props = {|
   components: Array<Component>,
-  onClick: Component => void,
+  onClick: string => void,
   metric: Metric,
   metrics: { [string]: Metric },
   selectedComponent?: ?string
-};
+|};
 
 export default function ComponentsList({
   components,
index deab1a9845325eac888bd797562789ba9e550f75..bbb2ca2fe8c3d7ef56ec96f38447367c207f84b0 100644 (file)
@@ -24,47 +24,37 @@ import MeasureCell from './MeasureCell';
 import type { Component } from '../../types';
 import type { Metric } from '../../../../store/metrics/actions';
 
-type Props = {
+type Props = {|
   component: Component,
   isSelected: boolean,
-  onClick: Component => void,
+  onClick: string => void,
   otherMetrics: Array<Metric>,
   metric: Metric
-};
+|};
 
-export default class ComponentsListRow extends React.PureComponent {
-  props: Props;
+export default function ComponentsListRow(props: Props) {
+  const { component } = props;
+  const otherMeasures = props.otherMetrics.map(metric => {
+    const measure = component.measures.find(measure => measure.metric === metric.key);
+    return { ...measure, metric };
+  });
+  return (
+    <tr>
+      <ComponentCell component={component} isSelected={props.isSelected} onClick={props.onClick} />
 
-  handleClick = () => this.props.onClick(this.props.component);
+      <MeasureCell component={component} metric={props.metric} />
 
-  render() {
-    const { component } = this.props;
-    const otherMeasures = this.props.otherMetrics.map(metric => {
-      const measure = component.measures.find(measure => measure.metric === metric.key);
-      return { ...measure, metric };
-    });
-    return (
-      <tr>
-        <ComponentCell
-          component={component}
-          isSelected={this.props.isSelected}
-          onClick={this.handleClick}
+      {otherMeasures.map(measure =>
+        <MeasureCell
+          key={measure.metric.key}
+          component={{
+            ...component,
+            value: measure.value,
+            leak: measure.leak
+          }}
+          metric={measure.metric}
         />
-
-        <MeasureCell component={component} metric={this.props.metric} />
-
-        {otherMeasures.map(measure =>
-          <MeasureCell
-            key={measure.metric.key}
-            component={{
-              ...component,
-              value: measure.value,
-              leak: measure.leak
-            }}
-            metric={measure.metric}
-          />
-        )}
-      </tr>
-    );
-  }
+      )}
+    </tr>
+  );
 }
index 2e2d12dff72c6a1f9fa18880496db3c40fec698e..912a74ea31df834c6c8e80a1f22ec43804ae3fcd 100644 (file)
 import React from 'react';
 import ComponentsList from './ComponentsList';
 import ListFooter from '../../../../components/controls/ListFooter';
-import type { Component, ComponentEnhanced, Paging } from '../../types';
+import type { ComponentEnhanced, Paging } from '../../types';
 import type { Metric } from '../../../../store/metrics/actions';
 
-type Props = {
+type Props = {|
   components: Array<ComponentEnhanced>,
   fetchMore: () => void,
-  handleSelect: Component => void,
+  handleSelect: string => void,
   metric: Metric,
   metrics: { [string]: Metric },
   paging: ?Paging
-};
+|};
 
 export default function ListView(props: Props) {
   return (
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/TreeMapView.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/TreeMapView.js
new file mode 100644 (file)
index 0000000..41fc108
--- /dev/null
@@ -0,0 +1,182 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+// @flow
+import React from 'react';
+import { AutoSizer } from 'react-virtualized';
+import { scaleLinear, scaleOrdinal } from 'd3-scale';
+import QualifierIcon from '../../../../components/shared/QualifierIcon';
+import TreeMap from '../../../../components/charts/TreeMap';
+import {
+  translate,
+  translateWithParameters,
+  getLocalizedMetricName
+} from '../../../../helpers/l10n';
+import { formatMeasure, isDiffMetric } from '../../../../helpers/measures';
+import { getComponentUrl } from '../../../../helpers/urls';
+import type { Metric } from '../../../../store/metrics/actions';
+import type { ComponentEnhanced } from '../../types';
+import type { TreeMapItem } from '../../../../components/charts/TreeMap';
+
+type Props = {|
+  components: Array<ComponentEnhanced>,
+  handleSelect: string => void,
+  metric: Metric
+|};
+
+type State = {
+  treemapItems: Array<TreeMapItem>
+};
+
+const HEIGHT = 500;
+
+export default class TreeMapView extends React.PureComponent {
+  props: Props;
+  state: State;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = { treemapItems: this.getTreemapComponents(props) };
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.components !== this.props.components) {
+      this.setState({ treemapItems: this.getTreemapComponents(nextProps) });
+    }
+  }
+
+  getTreemapComponents = ({ components, metric }: Props): Array<TreeMapItem> => {
+    const colorScale = this.getColorScale(metric);
+    return components
+      .map(component => {
+        const colorMeasure = component.measures.find(measure => measure.metric.key === metric.key);
+        const sizeMeasure = component.measures.find(measure => measure.metric.key !== metric.key);
+        if (colorMeasure == null || sizeMeasure == null) {
+          // $FlowFixMe Null values are filtered just after
+          return null;
+        }
+        const colorValue = isDiffMetric(colorMeasure.metric.key)
+          ? colorMeasure.leak
+          : colorMeasure.value;
+        const sizeValue = isDiffMetric(sizeMeasure.metric.key)
+          ? sizeMeasure.leak
+          : sizeMeasure.value;
+        if (sizeValue == null) {
+          // $FlowFixMe Null values are filtered just after
+          return null;
+        }
+        return {
+          key: component.key,
+          size: sizeValue,
+          color: colorValue != null ? colorScale(colorValue) : '#777',
+          icon: <QualifierIcon qualifier={component.qualifier} />,
+          tooltip: this.getTooltip(
+            component.name,
+            colorMeasure.metric,
+            sizeMeasure.metric,
+            colorValue,
+            sizeValue
+          ),
+          label: component.name,
+          link: getComponentUrl(component.key)
+        };
+      })
+      .filter(component => component != null);
+  };
+
+  getLevelColorScale = () =>
+    scaleOrdinal()
+      .domain(['ERROR', 'WARN', 'OK', 'NONE'])
+      .range(['#d4333f', '#ed7d20', '#00aa00', '#b4b4b4']);
+
+  getPercentColorScale = (metric: Metric) => {
+    const color = scaleLinear().domain([0, 25, 50, 75, 100]);
+    color.range(
+      metric.direction === 1
+        ? ['#d4333f', '#ed7d20', '#eabe06', '#b0d513', '#00aa00']
+        : ['#00aa00', '#b0d513', '#eabe06', '#ed7d20', '#d4333f']
+    );
+    return color;
+  };
+
+  getRatingColorScale = () =>
+    scaleLinear()
+      .domain([1, 2, 3, 4, 5])
+      .range(['#00aa00', '#b0d513', '#eabe06', '#ed7d20', '#d4333f']);
+
+  getColorScale = (metric: Metric) => {
+    if (metric.type === 'LEVEL') {
+      return this.getLevelColorScale();
+    }
+    if (metric.type === 'RATING') {
+      return this.getRatingColorScale();
+    }
+    return this.getPercentColorScale(metric);
+  };
+
+  getTooltip = (
+    componentName: string,
+    colorMetric: Metric,
+    sizeMetric: Metric,
+    colorValue: ?number,
+    sizeValue: number
+  ) => {
+    const formatted =
+      colorMetric != null && colorValue != null ? formatMeasure(colorValue, colorMetric.type) : '—';
+    return (
+      <div className="text-left">
+        {componentName}
+        <br />
+        {`${getLocalizedMetricName(sizeMetric)}: ${formatMeasure(sizeValue, sizeMetric.type)}`}
+        <br />
+        {`${getLocalizedMetricName(colorMetric)}: ${formatted}`}
+      </div>
+    );
+  };
+
+  render() {
+    return (
+      <div className="measure-details-treemap">
+        <ul className="list-inline note spacer-bottom">
+          <li>
+            {translateWithParameters(
+              'component_measures.legend.color_x',
+              getLocalizedMetricName(this.props.metric)
+            )}
+          </li>
+          <li>
+            {translateWithParameters(
+              'component_measures.legend.size_x',
+              translate('metric.ncloc.name')
+            )}
+          </li>
+        </ul>
+        <AutoSizer>
+          {({ width }) =>
+            <TreeMap
+              items={this.state.treemapItems}
+              onRectangleClick={this.props.handleSelect}
+              height={HEIGHT}
+              width={width}
+            />}
+        </AutoSizer>
+      </div>
+    );
+  }
+}
index 74ef48f3c6332fbe3c21adc6563d903f07efd85e..bf126b8a5b4969b65352b438d837f04161807a7a 100644 (file)
@@ -49,7 +49,9 @@ export default class Sidebar extends React.PureComponent {
     }));
   };
 
-  changeMetric = (metric: string) => this.props.updateQuery({ metric, selected: null });
+  resetSelection = () => ({ selected: null, view: 'list' });
+
+  changeMetric = (metric: string) => this.props.updateQuery({ metric, ...this.resetSelection() });
 
   render() {
     return (
index 74f1c8c433537e68824dca272bb33de0238c4bcf..5f00fa61bf783fa6b1127bf1d948bcc53f6dc368 100644 (file)
@@ -72,8 +72,12 @@ export function sortMeasures(
   ]);
 }
 
-export const enhanceComponent = (component: Component, metric: Metric): ComponentEnhanced => {
-  const enhancedMeasures = component.measures.map(measure => enhanceMeasure(measure, metric));
+export const enhanceComponent = (
+  component: Component,
+  metric: Metric,
+  metrics: { [string]: Metric }
+): ComponentEnhanced => {
+  const enhancedMeasures = component.measures.map(measure => enhanceMeasure(measure, metrics));
   const measure = enhancedMeasures.find(measure => measure.metric.key === metric.key);
   const value = measure ? measure.value : null;
   const leak = measure ? measure.leak : null;
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 (file)
index 0000000..b5eea12
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+// @flow
+import React from 'react';
+import { treemap as d3Treemap, hierarchy as d3Hierarchy } from 'd3-hierarchy';
+import TreeMapRect from './TreeMapRect';
+import { translate } from '../../helpers/l10n';
+
+export type TreeMapItem = {
+  key: string,
+  size: number,
+  color: string,
+  icon?: React.Element<*>,
+  tooltip?: string | React.Element<*>,
+  label: string,
+  link?: string
+};
+
+type Props = {|
+  items: Array<TreeMapItem>,
+  onRectangleClick?: string => void,
+  height: number,
+  width: number
+|};
+
+export default class TreeMap extends React.PureComponent {
+  props: Props;
+
+  mostCommitPrefix = (labels: Array<string>) => {
+    const sortedLabels = labels.slice(0).sort();
+    const firstLabel = sortedLabels[0];
+    const firstLabelLength = firstLabel.length;
+    const lastLabel = sortedLabels[sortedLabels.length - 1];
+    let i = 0;
+    while (i < firstLabelLength && firstLabel.charAt(i) === lastLabel.charAt(i)) {
+      i++;
+    }
+    const prefix = firstLabel.substr(0, i);
+    const prefixTokens = prefix.split(/[\s\\\/]/);
+    const lastPrefixPart = prefixTokens[prefixTokens.length - 1];
+    return prefix.substr(0, prefix.length - lastPrefixPart.length);
+  };
+
+  renderNoData() {
+    return (
+      <div className="sonar-d3">
+        <div
+          className="treemap-container"
+          style={{ width: this.props.width, height: this.props.height }}>
+          {translate('no_data')}
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { items, height, width } = this.props;
+    if (items.length <= 0) {
+      return this.renderNoData();
+    }
+
+    const hierarchy = d3Hierarchy({ children: items })
+      .sum(d => d.size)
+      .sort((a, b) => b.value - a.value);
+
+    const treemap = d3Treemap().round(true).size([width, height]);
+
+    const nodes = treemap(hierarchy).leaves();
+    const prefix = this.mostCommitPrefix(items.map(item => item.label));
+    return (
+      <div className="sonar-d3">
+        <div className="treemap-container" style={{ width, height }}>
+          {nodes.map(node =>
+            <TreeMapRect
+              key={node.data.key}
+              x={node.x0}
+              y={node.y0}
+              width={node.x1 - node.x0}
+              height={node.y1 - node.y0}
+              fill={node.data.color}
+              label={node.data.label}
+              prefix={prefix}
+              itemKey={node.data.key}
+              icon={node.data.icon}
+              tooltip={node.data.tooltip}
+              link={node.data.link}
+              onClick={this.props.onRectangleClick}
+            />
+          )}
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/charts/TreeMapRect.js b/server/sonar-web/src/main/js/components/charts/TreeMapRect.js
new file mode 100644 (file)
index 0000000..c0bc103
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+// @flow
+import React from 'react';
+import classNames from 'classnames';
+import { scaleLinear } from 'd3-scale';
+import Tooltip from '../controls/Tooltip';
+
+const SIZE_SCALE = scaleLinear().domain([3, 15]).range([11, 18]).clamp(true);
+
+type Props = {|
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  fill: string,
+  label: string,
+  prefix: string,
+  icon?: React.Element<*>,
+  tooltip?: string | React.Element<*>,
+  itemKey: string,
+  link?: string,
+  onClick?: string => void
+|};
+
+export default class TreeMapRect extends React.PureComponent {
+  props: Props;
+
+  handleLinkClick = (e: Event) => e.stopPropagation();
+
+  handleRectClick = () => {
+    if (this.props.onClick != null) {
+      this.props.onClick(this.props.itemKey);
+    }
+  };
+
+  renderLink = () => {
+    const { link, height, width } = this.props;
+    if (link == null) {
+      return null;
+    }
+
+    if (width >= 24 && height >= 24 && (width >= 48 || height >= 50)) {
+      return (
+        <a className="treemap-link" href={link} onClick={this.handleLinkClick}>
+          <span className="icon-link" />
+        </a>
+      );
+    }
+  };
+
+  renderCell = () => {
+    const 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`,
+      cursor: this.props.onClick != null ? 'pointer' : 'default'
+    };
+    const isTextVisible = this.props.width >= 40 && this.props.height >= 45;
+    const isIconVisible = this.props.width >= 24 && this.props.height >= 26;
+
+    const label = this.props.prefix
+      ? `${this.props.prefix}<br>${this.props.label.substr(this.props.prefix.length)}`
+      : this.props.label;
+
+    return (
+      <div
+        className="treemap-cell"
+        style={cellStyles}
+        onClick={this.handleRectClick}
+        role="treeitem"
+        tabIndex={0}>
+        <div className="treemap-inner" style={{ maxWidth: this.props.width }}>
+          {isIconVisible &&
+            <span className={classNames('treemap-icon', { 'spacer-right': isTextVisible })}>
+              {this.props.icon}
+            </span>}
+          {isTextVisible &&
+            <span className="treemap-text" dangerouslySetInnerHTML={{ __html: label }} />}
+        </div>
+        {this.renderLink()}
+      </div>
+    );
+  };
+
+  render() {
+    const { tooltip } = this.props;
+    if (tooltip != null) {
+      return (
+        <Tooltip overlay={tooltip}>
+          {this.renderCell()}
+        </Tooltip>
+      );
+    }
+    return this.renderCell();
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/TreeMap-test.js b/server/sonar-web/src/main/js/components/charts/__tests__/TreeMap-test.js
new file mode 100644 (file)
index 0000000..181f60b
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+import TreeMap from '../TreeMap';
+
+it('should display', () => {
+  const items = [
+    { key: '1', size: 10, color: '#777', label: 'SonarQube :: Server' },
+    { key: '2', size: 30, color: '#777', label: 'SonarQube :: Web' },
+    { key: '3', size: 20, color: '#777', label: 'SonarQube :: Search' }
+  ];
+  const chart = shallow(
+    <TreeMap items={items} width={100} height={100} onRectangleClick={() => {}} />
+  );
+  expect(chart.find('TreeMapRect')).toHaveLength(3);
+});
diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/treemap-test.js b/server/sonar-web/src/main/js/components/charts/__tests__/treemap-test.js
deleted file mode 100644 (file)
index dea905a..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import React from 'react';
-import { shallow } from 'enzyme';
-import { Treemap, TreemapRect } from '../treemap';
-
-it('should display', () => {
-  const items = [
-    { size: 10, color: '#777', label: 'SonarQube :: Server' },
-    { size: 30, color: '#777', label: 'SonarQube :: Web' },
-    { size: 20, color: '#777', label: 'SonarQube :: Search' }
-  ];
-  const chart = shallow(
-    <Treemap items={items} width={100} height={100} breadcrumbs={[]} canBeClicked={() => true} />
-  );
-  expect(chart.find(TreemapRect).length).toBe(3);
-});
index 74cc616d6807c154683a9b17de2b03fc0480efcd..92fefd654ce1d24c770f0b981258d77ae792aa97 100644 (file)
@@ -29,10 +29,13 @@ import type { Metric } from '../../store/metrics/actions';
 
 const KNOWN_RATINGS = ['sqale_rating', 'reliability_rating', 'security_rating'];
 
-export const enhanceMeasure = (measure: Measure, metric: Metric): MeasureEnhanced => ({
+export const enhanceMeasure = (
+  measure: Measure,
+  metrics: { [string]: Metric }
+): MeasureEnhanced => ({
   value: measure.value,
   periods: measure.periods,
-  metric,
+  metric: metrics[measure.metric],
   leak: getLeakValue(measure)
 });
 
index d3ca9af128344429d85526a0e7208570199d555c..32648b5b3fff4bccd584d4f7a008298f25dd4c04 100644 (file)
   border-bottom: 1px solid #fff;
   box-sizing: border-box;
   text-align: center;
+
+  &:focus {
+    outline: none;
+  }
 }
 
 .sonar-d3 .treemap-inner {
-  display: inline-block;
+  display: inline-flex;
   vertical-align: middle;
-  line-height: 1.2;
-  padding: 5px;
+  align-items: center;
+  justify-content: center;
+  flex-wrap: wrap;
+  padding: 0 4px;
   box-sizing: border-box;
-  text-align: left;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  line-height: 1.2;
+
+  .treemap-icon {
+    flex-shrink: 0;
+  }
+
+  .treemap-text {
+    flex-shrink: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-align: left;
+  }
 }
 
 .sonar-d3 .treemap-link {
@@ -52,6 +67,8 @@
   right: 5px;
   line-height: @iconSmallFontSize;
   opacity: 0.5;
+  font-size: 12;
+
   .link-no-underline;
 
   &:hover {
   line-height: inherit;
 }
 
-.sonar-d3 .treemap-cell-small {
-  .treemap-inner {
-    display: none;
-  }
-}
-
-.sonar-d3 .treemap-cell-very-small {
-  .treemap-inner {
-    display: none;
-  }
-  .treemap-link {
-    display: none;
-  }
-}
-
 .sonar-d3 .treemap-breadcrumbs {
   margin-top: 10px;
   padding-top: 7px;