Browse Source

SONAR-9608 SONAR-9611 Create the project overview bubble chart on the measures page

tags/6.6-RC1
Grégoire Aubert 6 years ago
parent
commit
bb393dd277

+ 5
- 1
server/sonar-web/src/main/js/apps/component-measures/__tests__/utils-test.js View File

@@ -92,7 +92,11 @@ describe('groupByDomains', () => {

describe('parseQuery', () => {
it('should correctly parse the url query', () => {
expect(utils.parseQuery({})).toEqual({ metric: '', selected: '', view: utils.DEFAULT_VIEW });
expect(utils.parseQuery({})).toEqual({
metric: 'project_overview',
selected: '',
view: utils.DEFAULT_VIEW
});
expect(utils.parseQuery({ metric: 'foo', selected: 'bar', view: 'tree' })).toEqual({
metric: 'foo',
selected: 'bar',

+ 8
- 12
server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.js View File

@@ -26,8 +26,7 @@ import MeasureFavoriteContainer from './MeasureFavoriteContainer';
import PageActions from './PageActions';
import SourceViewer from '../../../components/SourceViewer/SourceViewer';
import { getComponentLeaves } from '../../../api/components';
import { enhanceComponent, isFileType } from '../utils';
import { bubbles } from '../config/bubbles';
import { enhanceComponent, getBubbleMetrics, isFileType } from '../utils';
import type { Component, ComponentEnhanced, Paging, Period } from '../types';
import type { Metric } from '../../../store/metrics/actions';

@@ -78,22 +77,19 @@ export default class MeasureOverview extends React.PureComponent {
this.mounted = false;
}

getBubbleMetrics = ({ domain, metrics }: Props) => {
const conf = bubbles[domain];
return {
xMetric: metrics[conf.x],
yMetric: metrics[conf.y],
sizeMetric: metrics[conf.size]
};
};

fetchComponents = (props: Props) => {
const { component, metrics } = props;
if (isFileType(component)) {
return this.setState({ components: [], paging: null });
}
const { xMetric, yMetric, sizeMetric } = this.getBubbleMetrics(props);
const { xMetric, yMetric, sizeMetric, colorsMetric } = getBubbleMetrics(
props.domain,
props.metrics
);
const metricsKey = [xMetric.key, yMetric.key, sizeMetric.key];
if (colorsMetric) {
metricsKey.push(colorsMetric.map(metric => metric.key));
}
const options = {
s: 'metric',
metricSort: sizeMetric.key,

+ 25
- 4
server/sonar-web/src/main/js/apps/component-measures/config/bubbles.js View File

@@ -19,9 +19,30 @@
*/
// @flow
export const bubbles = {
Reliability: { x: 'ncloc', y: 'reliability_remediation_effort', size: 'bugs' },
Security: { x: 'ncloc', y: 'security_remediation_effort', size: 'vulnerabilities' },
Maintainability: { x: 'ncloc', y: 'sqale_index', size: 'code_smells' },
Reliability: {
x: 'ncloc',
y: 'reliability_remediation_effort',
size: 'bugs',
colors: ['reliability_rating']
},
Security: {
x: 'ncloc',
y: 'security_remediation_effort',
size: 'vulnerabilities',
colors: ['security_rating']
},
Maintainability: {
x: 'ncloc',
y: 'sqale_index',
size: 'code_smells',
colors: ['sqale_rating']
},
Coverage: { x: 'complexity', y: 'coverage', size: 'uncovered_lines' },
Duplications: { x: 'ncloc', y: 'duplicated_lines', size: 'duplicated_blocks' }
Duplications: { x: 'ncloc', y: 'duplicated_lines', size: 'duplicated_blocks' },
project_overview: {
x: 'sqale_index',
y: 'coverage',
size: 'ncloc',
colors: ['reliability_rating', 'security_rating']
}
};

+ 87
- 35
server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.js View File

@@ -21,13 +21,16 @@
import React from 'react';
import EmptyResult from './EmptyResult';
import OriginalBubbleChart from '../../../components/charts/BubbleChart';
import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend';
import { formatMeasure, isDiffMetric } from '../../../helpers/measures';
import {
getLocalizedMetricDomain,
getLocalizedMetricName,
translate,
translateWithParameters
} from '../../../helpers/l10n';
import { bubbles } from '../config/bubbles';
import { getBubbleMetrics, isProjectOverview } from '../utils';
import { RATING_COLORS } from '../../../helpers/constants';
import type { Component, ComponentEnhanced } from '../types';
import type { Metric } from '../../../store/metrics/actions';

@@ -44,15 +47,6 @@ type Props = {|
export default class BubbleChart extends React.PureComponent {
props: Props;

getBubbleMetrics = ({ domain, metrics }: Props) => {
const conf = bubbles[domain];
return {
xMetric: metrics[conf.x],
yMetric: metrics[conf.y],
sizeMetric: metrics[conf.size]
};
};

getMeasureVal = (component: ComponentEnhanced, metric: Metric) => {
const measure = component.measures.find(measure => measure.metric.key === metric.key);
if (measure) {
@@ -65,27 +59,45 @@ export default class BubbleChart extends React.PureComponent {
x: number,
y: number,
size: number,
colors: ?Array<?number>,
xMetric: Metric,
yMetric: Metric,
sizeMetric: Metric
sizeMetric: Metric,
colorsMetric: ?Array<Metric>
) {
const inner = [
componentName,
`${xMetric.name}: ${formatMeasure(x, xMetric.type)}`,
`${yMetric.name}: ${formatMeasure(y, yMetric.type)}`,
`${sizeMetric.name}: ${formatMeasure(size, sizeMetric.type)}`
].join('<br>');
return `<div class="text-left">${inner}</div>`;
];
if (colors && colorsMetric) {
colorsMetric.forEach((metric, idx) => {
// $FlowFixMe colors is always defined at this point
const colorValue = colors[idx];
if (colorValue || colorValue === 0) {
inner.push(`${metric.name}: ${formatMeasure(colorValue, metric.type)}`);
}
});
}
return `<div class="text-left">${inner.join('<br/>')}</div>`;
}

handleBubbleClick = (component: ComponentEnhanced) => this.props.updateSelected(component.key);

renderBubbleChart(xMetric: Metric, yMetric: Metric, sizeMetric: Metric) {
renderBubbleChart(
xMetric: Metric,
yMetric: Metric,
sizeMetric: Metric,
colorsMetric: ?Array<Metric>
) {
const items = this.props.components
.map(component => {
const x = this.getMeasureVal(component, xMetric);
const y = this.getMeasureVal(component, yMetric);
const size = this.getMeasureVal(component, sizeMetric);
const colors =
colorsMetric && colorsMetric.map(metric => this.getMeasureVal(component, metric));
if ((!x && x !== 0) || (!y && y !== 0) || (!size && size !== 0)) {
return null;
}
@@ -93,8 +105,20 @@ export default class BubbleChart extends React.PureComponent {
x,
y,
size,
color:
colors != null ? RATING_COLORS[Math.max(...colors.filter(Boolean)) - 1] : undefined,
link: component,
tooltip: this.getTooltip(component.name, x, y, size, xMetric, yMetric, sizeMetric)
tooltip: this.getTooltip(
component.name,
x,
y,
size,
colors,
xMetric,
yMetric,
sizeMetric,
colorsMetric
)
};
})
.filter(Boolean);
@@ -114,35 +138,63 @@ export default class BubbleChart extends React.PureComponent {
);
}

render() {
if (this.props.components.length <= 0) {
return <EmptyResult />;
}

const { xMetric, yMetric, sizeMetric } = this.getBubbleMetrics(this.props);
renderChartHeader(domain: string, sizeMetric: Metric, colorsMetric: ?Array<Metric>) {
const title = isProjectOverview(domain)
? translate('component_measures.overview', domain, 'title')
: translateWithParameters(
'component_measures.domain_x_overview',
getLocalizedMetricDomain(domain)
);
return (
<div className="measure-details-bubble-chart">
<div className="measure-details-bubble-chart-header">
<span>
{translateWithParameters(
'component_measures.domain_x_overview',
getLocalizedMetricDomain(this.props.domain)
)}
</span>
<span className="measure-details-bubble-chart-legend">
<div className="measure-overview-bubble-chart-header">
<span className="measure-overview-bubble-chart-title">
{title}
</span>
<span className="measure-overview-bubble-chart-legend">
<span className="note">
{colorsMetric &&
<span className="spacer-right">
{translateWithParameters(
'component_measures.legend.color_x',
colorsMetric.length > 1
? translateWithParameters(
'component_measures.legend.worse_of_x_y',
...colorsMetric.map(metric => getLocalizedMetricName(metric))
)
: getLocalizedMetricName(colorsMetric[0])
)}
</span>}
{translateWithParameters(
'component_measures.legend.size_x',
getLocalizedMetricName(sizeMetric)
)}
</span>
{colorsMetric && <ColorRatingsLegend className="spacer-top" />}
</span>
</div>
);
}

render() {
if (this.props.components.length <= 0) {
return <EmptyResult />;
}
const { domain } = this.props;
const { xMetric, yMetric, sizeMetric, colorsMetric } = getBubbleMetrics(
domain,
this.props.metrics
);

return (
<div className="measure-overview-bubble-chart">
{this.renderChartHeader(domain, sizeMetric, colorsMetric)}
<div className="measure-overview-bubble-chart-content">
{this.renderBubbleChart(xMetric, yMetric, sizeMetric, colorsMetric)}
</div>
<div>
{this.renderBubbleChart(xMetric, yMetric, sizeMetric)}
</div>
<div className="measure-details-bubble-chart-axis x">
<div className="measure-overview-bubble-chart-axis x">
{getLocalizedMetricName(xMetric)}
</div>
<div className="measure-details-bubble-chart-axis y">
<div className="measure-overview-bubble-chart-axis y">
{getLocalizedMetricName(yMetric)}
</div>
</div>

+ 61
- 0
server/sonar-web/src/main/js/apps/component-measures/sidebar/ProjectOverviewFacet.js View File

@@ -0,0 +1,61 @@
/*
* 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 FacetBox from '../../../components/facet/FacetBox';
import FacetItem from '../../../components/facet/FacetItem';
import FacetItemsList from '../../../components/facet/FacetItemsList';
import Tooltip from '../../../components/controls/Tooltip';
import { translate } from '../../../helpers/l10n';

type Props = {|
onChange: (metric: string) => void,
selected: string,
value: string
|};

export default class ProjectOverviewFacet extends React.PureComponent {
props: Props;

render() {
const { value, selected } = this.props;
const facetName = translate('component_measures.overview', value, 'facet');
return (
<FacetBox>
<FacetItemsList>
<FacetItem
active={value === selected}
disabled={false}
key={value}
name={
<Tooltip overlay={facetName} mouseEnterDelay={0.5}>
<strong id={`measure-overview-${value}-name`}>
{facetName}
</strong>
</Tooltip>
}
onClick={this.props.onChange}
value={value}
/>
</FacetItemsList>
</FacetBox>
);
}
}

+ 7
- 1
server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.js View File

@@ -19,8 +19,9 @@
*/
// @flow
import React from 'react';
import ProjectOverviewFacet from './ProjectOverviewFacet';
import DomainFacet from './DomainFacet';
import { groupByDomains } from '../utils';
import { groupByDomains, PROJECT_OVERVEW } from '../utils';
import type { MeasureEnhanced } from '../../../components/measure/types';
import type { Query } from '../types';

@@ -56,6 +57,11 @@ export default class Sidebar extends React.PureComponent {
render() {
return (
<div className="search-navigator-facets-list">
<ProjectOverviewFacet
onChange={this.changeMetric}
selected={this.props.selectedMetric}
value={PROJECT_OVERVEW}
/>
{groupByDomains(this.props.measures).map(domain =>
<DomainFacet
key={domain.name}

+ 5
- 0
server/sonar-web/src/main/js/apps/component-measures/sidebar/__tests__/__snapshots__/Sidebar-test.js.snap View File

@@ -4,6 +4,11 @@ exports[`should display two facets 1`] = `
<div
className="search-navigator-facets-list"
>
<ProjectOverviewFacet
onChange={[Function]}
selected="foo"
value="project_overview"
/>
<DomainFacet
domain={
Object {

+ 9
- 3
server/sonar-web/src/main/js/apps/component-measures/style.css View File

@@ -98,16 +98,22 @@
}

.measure-details-bubble-chart-header {
display: flex;
align-items: center;
padding: 16px;
margin-left: -60px;
border-bottom: 1px solid #e6e6e6;
}

.measure-details-bubble-chart-legend {
.measure-details-bubble-chart-title {
position: absolute;
width: 100%;
left: 0;
}

.measure-details-bubble-chart-legend {
display: flex;
flex-direction: column;
text-align: center;
flex-grow: 1;
}

.measure-details-bubble-chart-axis {

+ 16
- 2
server/sonar-web/src/main/js/apps/component-measures/utils.js View File

@@ -29,7 +29,9 @@ import type { RawQuery } from '../../helpers/query';
import type { Metric } from '../../store/metrics/actions';
import type { MeasureEnhanced } from '../../components/measure/types';

export const PROJECT_OVERVEW = 'project_overview';
export const DEFAULT_VIEW = 'list';
export const DEFAULT_METRIC = PROJECT_OVERVEW;
const KNOWN_DOMAINS = [
'Releasability',
'Reliability',
@@ -112,15 +114,27 @@ export const hasTreemap = (metricType: string): boolean =>

export const hasBubbleChart = (domainName: string): boolean => bubbles[domainName] != null;

export const getBubbleMetrics = (domain: string, metrics: { [string]: Metric }) => {
const conf = bubbles[domain];
return {
xMetric: metrics[conf.x],
yMetric: metrics[conf.y],
sizeMetric: metrics[conf.size],
colorsMetric: conf.colors ? conf.colors.map(color => metrics[color]) : null
};
};

export const isProjectOverview = (metric: string) => metric === PROJECT_OVERVEW;

export const parseQuery = memoize((urlQuery: RawQuery): Query => ({
metric: parseAsString(urlQuery['metric']),
metric: parseAsString(urlQuery['metric']) || DEFAULT_METRIC,
selected: parseAsString(urlQuery['selected']),
view: parseAsString(urlQuery['view']) || DEFAULT_VIEW
}));

export const serializeQuery = memoize((query: Query): RawQuery => {
return cleanQuery({
metric: serializeString(query.metric),
metric: query.metric === DEFAULT_METRIC ? null : serializeString(query.metric),
selected: serializeString(query.selected),
view: query.view === DEFAULT_VIEW ? null : serializeString(query.view)
});

+ 0
- 25
server/sonar-web/src/main/js/apps/projects/styles.css View File

@@ -268,31 +268,6 @@
font-style: italic;
}

.projects-visualizations-ratings {
display: flex;
justify-content: center;
margin-top: 16px;
}

.projects-visualizations-ratings > *:not(:first-child) {
margin-left: 24px;
}

.projects-visualizations-ratings-rect {
display: inline-block;
vertical-align: top;
margin-top: 1px;
margin-right: 4px;
border: 1px solid;
}

.projects-visualizations-ratings-rect-inner {
display: block;
width: 8px;
height: 8px;
opacity: 0.2;
}

.measure-details-bubble-chart-axis {
position: absolute;
color: #777;

+ 2
- 2
server/sonar-web/src/main/js/apps/projects/visualizations/Risk.js View File

@@ -19,7 +19,7 @@
*/
// @flow
import React from 'react';
import RatingsLegend from './RatingsLegend';
import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend';
import BubbleChart from '../../../components/charts/BubbleChart';
import { formatMeasure } from '../../../helpers/measures';
import { translate, translateWithParameters } from '../../../helpers/l10n';
@@ -130,7 +130,7 @@ export default class Risk extends React.PureComponent {
'component_measures.legend.size_x',
translate('metric', SIZE_METRIC, 'name')
)}
<RatingsLegend />
<ColorRatingsLegend className="big-spacer-top" />
</div>
</div>
);

+ 2
- 2
server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.js View File

@@ -19,7 +19,7 @@
*/
// @flow
import React from 'react';
import RatingsLegend from './RatingsLegend';
import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend';
import BubbleChart from '../../../components/charts/BubbleChart';
import { formatMeasure } from '../../../helpers/measures';
import { translate, translateWithParameters } from '../../../helpers/l10n';
@@ -130,7 +130,7 @@ export default class SimpleBubbleChart extends React.PureComponent {
'component_measures.legend.size_x',
translate('metric', sizeMetric.key, 'name')
)}
{colorMetric != null && <RatingsLegend />}
{colorMetric != null && <ColorRatingsLegend className="big-spacer-top" />}
</div>
</div>
);

server/sonar-web/src/main/js/apps/projects/visualizations/RatingsLegend.js → server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.js View File

@@ -19,19 +19,20 @@
*/
// @flow
import React from 'react';
import { formatMeasure } from '../../../helpers/measures';
import { RATING_COLORS } from '../../../helpers/constants';
import classNames from 'classnames';
import { formatMeasure } from '../../helpers/measures';
import { RATING_COLORS } from '../../helpers/constants';

export default function RatingsLegend() {
export default function ColorRatingsLegend({ className }: { className?: string }) {
return (
<div className="projects-visualizations-ratings">
<div className={classNames('color-ratings-legend', className)}>
{[1, 2, 3, 4, 5].map(rating =>
<div key={rating}>
<span
className="projects-visualizations-ratings-rect"
className="color-ratings-legend-rect"
style={{ borderColor: RATING_COLORS[rating - 1] }}>
<span
className="projects-visualizations-ratings-rect-inner"
className="color-ratings-legend-rect-inner"
style={{ backgroundColor: RATING_COLORS[rating - 1] }}
/>
</span>

+ 24
- 0
server/sonar-web/src/main/less/components/graphics.less View File

@@ -263,6 +263,30 @@
text-anchor: end;
}

.color-ratings-legend {
display: flex;
justify-content: center;

& > *:not(:first-child) {
margin-left: 24px;
}

.color-ratings-legend-rect {
display: inline-block;
vertical-align: top;
margin-top: 1px;
margin-right: 4px;
border: 1px solid;
}

.color-ratings-legend-rect-inner {
display: block;
width: 8px;
height: 8px;
opacity: 0.2;
}
}

/*
* Bar Chart
*/

+ 4
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -2894,6 +2894,7 @@ component_measures.tab.treemap=Treemap
component_measures.tab.history=History
component_measures.legend.color_x=Color: {0}
component_measures.legend.size_x=Size: {0}
component_measures.legend.worse_of_x_y=Worse of {0} and {1}
component_measures.x_of_y={0} of {1}
component_measures.no_history=There is no historical data.
component_measures.not_found=The requested measure was not found.
@@ -2901,6 +2902,9 @@ component_measures.to_select_files=to select files
component_measures.to_navigate=to navigate
component_measures.to_navigate_back=to navigate back

component_measures.overview.project_overview.facet=Project Overview
component_measures.overview.project_overview.title=Risk

#------------------------------------------------------------------------------
#
# ABOUT PAGE

Loading…
Cancel
Save