@@ -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', |
@@ -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, |
@@ -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'] | |||
} | |||
}; |
@@ -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> |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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} |
@@ -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 { |
@@ -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 { |
@@ -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) | |||
}); |
@@ -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; |
@@ -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> | |||
); |
@@ -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> | |||
); |
@@ -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> |
@@ -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 | |||
*/ |
@@ -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 |