diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-08-04 11:27:20 +0200 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-08-14 11:44:44 +0200 |
commit | 3d7ad29b570e76695fd4b53ba6d1ad127e5c92f3 (patch) | |
tree | db3b14fa3a6107698f76a8125db9731a2d5075da /server | |
parent | e26ecd2e39df60a3ac0634a8b797a5ebec218b4c (diff) | |
download | sonarqube-3d7ad29b570e76695fd4b53ba6d1ad127e5c92f3.tar.gz sonarqube-3d7ad29b570e76695fd4b53ba6d1ad127e5c92f3.zip |
SONAR-9608 SONAR-9637 Add the overview bubble charts on the measures page
Diffstat (limited to 'server')
13 files changed, 550 insertions, 24 deletions
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/App.js b/server/sonar-web/src/main/js/apps/component-measures/components/App.js index 773235ee8ca..2a4e2ad6b62 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/App.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/App.js @@ -21,8 +21,9 @@ import React from 'react'; import Helmet from 'react-helmet'; import MeasureContentContainer from './MeasureContentContainer'; +import MeasureOverviewContainer from './MeasureOverviewContainer'; import Sidebar from '../sidebar/Sidebar'; -import { parseQuery, serializeQuery } from '../utils'; +import { hasBubbleChart, parseQuery, serializeQuery } from '../utils'; import { translate } from '../../../helpers/l10n'; import type { Component, Query, Period } from '../types'; import type { RawQuery } from '../../../helpers/query'; @@ -132,8 +133,10 @@ export default class App extends React.PureComponent { if (isLoading) { return <i className="spinner spinner-margin" />; } + const { component, fetchMeasures, metrics } = this.props; + const { leakPeriod } = this.state; const query = parseQuery(this.props.location.query); - const metric = this.props.metrics[query.metric]; + const metric = metrics[query.metric]; return ( <div className="layout-page" id="component-measures"> <Helmet title={translate('layout.measures')} /> @@ -156,15 +159,27 @@ export default class App extends React.PureComponent { <MeasureContentContainer className="layout-page-main" currentUser={this.props.currentUser} - rootComponent={this.props.component} - fetchMeasures={this.props.fetchMeasures} - leakPeriod={this.state.leakPeriod} + rootComponent={component} + fetchMeasures={fetchMeasures} + leakPeriod={leakPeriod} metric={metric} - metrics={this.props.metrics} + metrics={metrics} selected={query.selected} updateQuery={this.updateQuery} view={query.view} />} + {metric == null && + hasBubbleChart(query.metric) && + <MeasureOverviewContainer + className="layout-page-main" + rootComponent={component} + currentUser={this.props.currentUser} + domain={query.metric} + leakPeriod={leakPeriod} + metrics={metrics} + selected={query.selected} + updateQuery={this.updateQuery} + />} </div> ); } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/LeakPeriodLegend.js b/server/sonar-web/src/main/js/apps/component-measures/components/LeakPeriodLegend.js index 1d36e84a078..dbf0ae7ffd0 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/LeakPeriodLegend.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/LeakPeriodLegend.js @@ -19,29 +19,31 @@ */ // @flow import React from 'react'; +import classNames from 'classnames'; import moment from 'moment'; import Tooltip from '../../../components/controls/Tooltip'; import { getPeriodLabel, getPeriodDate } from '../../../helpers/periods'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import type { Component, Period } from '../types'; -export default function LeakPeriodLegend({ - component, - period -}: { +type Props = { + className?: string, component: Component, period: Period -}) { +}; + +export default function LeakPeriodLegend({ className, component, period }: Props) { + const leakClass = classNames('domain-measures-leak-header', className); if (component.qualifier === 'APP') { return ( - <div className="domain-measures-leak-header"> + <div className={leakClass}> {translate('issues.leak_period')} </div> ); } const label = ( - <div className="domain-measures-leak-header"> + <div className={leakClass}> {translateWithParameters('overview.leak_period_x', getPeriodLabel(period))} </div> ); diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContentContainer.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContentContainer.js index 08378cccae7..f9948e1028d 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContentContainer.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContentContainer.js @@ -24,7 +24,7 @@ import type { Component, Period, Query } from '../types'; import type { MeasureEnhanced } from '../../../components/measure/types'; import type { Metric } from '../../../store/metrics/actions'; -type Props = { +type Props = {| className?: string, currentUser: { isLoggedIn: boolean }, rootComponent: Component, @@ -38,7 +38,7 @@ type Props = { selected: ?string, updateQuery: Query => void, view: string -}; +|}; type State = { component: ?Component, diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.js new file mode 100644 index 00000000000..cab6f21fc1a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.js @@ -0,0 +1,181 @@ +/* + * 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 Breadcrumbs from './Breadcrumbs'; +import BubbleChart from '../drilldown/BubbleChart'; +import Favorite from '../../../components/controls/Favorite'; +import LeakPeriodLegend from './LeakPeriodLegend'; +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 type { Component, ComponentEnhanced, Paging, Period } from '../types'; +import type { Metric } from '../../../store/metrics/actions'; + +type Props = {| + className?: string, + component: Component, + currentUser: { isLoggedIn: boolean }, + domain: string, + leakPeriod: Period, + loading: boolean, + metrics: { [string]: Metric }, + rootComponent: Component, + updateLoading: ({ [string]: boolean }) => void, + updateSelected: string => void +|}; + +type State = { + components: Array<ComponentEnhanced>, + paging?: Paging +}; + +const BUBBLES_LIMIT = 500; + +export default class MeasureOverview extends React.PureComponent { + mounted: boolean; + props: Props; + state: State = { + components: [], + paging: null + }; + + componentDidMount() { + this.mounted = true; + this.fetchComponents(this.props); + } + + componentWillReceiveProps(nextProps: Props) { + if ( + nextProps.component !== this.props.component || + nextProps.metrics !== this.props.metrics || + nextProps.domain !== this.props.domain + ) { + this.fetchComponents(nextProps); + } + } + + componentWillUnmount() { + 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 metricsKey = [xMetric.key, yMetric.key, sizeMetric.key]; + const options = { + s: 'metric', + metricSort: sizeMetric.key, + asc: false, + ps: BUBBLES_LIMIT + }; + + this.props.updateLoading({ bubbles: true }); + getComponentLeaves(component.key, metricsKey, options).then( + r => { + if (this.mounted) { + this.setState({ + components: r.components.map(component => enhanceComponent(component, null, metrics)), + paging: r.paging + }); + this.props.updateLoading({ bubbles: false }); + } + }, + () => this.props.updateLoading({ bubbles: false }) + ); + }; + + renderContent() { + const { component } = this.props; + if (isFileType(component)) { + return ( + <div className="measure-details-viewer"> + <SourceViewer component={component.key} /> + </div> + ); + } + + return ( + <BubbleChart + component={this.props.component} + components={this.state.components} + domain={this.props.domain} + metrics={this.props.metrics} + updateSelected={this.props.updateSelected} + /> + ); + } + + render() { + const { component, currentUser, leakPeriod, rootComponent } = 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"> + <div className="layout-page-header-panel-inner layout-page-main-header-inner"> + <div className="layout-page-main-inner clearfix"> + <Breadcrumbs + className="measure-breadcrumbs spacer-right text-ellipsis" + component={component} + handleSelect={this.props.updateSelected} + rootComponent={rootComponent} + /> + {component.key !== rootComponent.key && + isLoggedIn && + <Favorite + favorite={component.isFavorite === true} + component={component.key} + className="measure-favorite spacer-right" + />} + <PageActions + current={this.state.components.length} + loading={this.props.loading} + isFile={isFile} + paging={this.state.paging} + /> + </div> + </div> + </div> + <div className="layout-page-main-inner"> + <div className="clearfix big-spacer-bottom"> + {leakPeriod != null && + <LeakPeriodLegend className="pull-right" component={component} period={leakPeriod} />} + </div> + {!this.props.loading && this.renderContent()} + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.js new file mode 100644 index 00000000000..b674dbc9e8a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.js @@ -0,0 +1,126 @@ +/* + * 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 MeasureOverview from './MeasureOverview'; +import { getComponentShow } from '../../../api/components'; +import type { Component, Period, Query } from '../types'; +import type { Metric } from '../../../store/metrics/actions'; + +type Props = {| + className?: string, + rootComponent: Component, + currentUser: { isLoggedIn: boolean }, + domain: string, + leakPeriod: Period, + metrics: { [string]: Metric }, + selected: ?string, + updateQuery: Query => void +|}; + +type State = { + component: ?Component, + loading: { + component: boolean, + bubbles: boolean + } +}; + +export default class MeasureOverviewContainer extends React.PureComponent { + mounted: boolean; + props: Props; + state: State = { + component: null, + loading: { + component: false, + bubbles: false + } + }; + + componentDidMount() { + this.mounted = true; + this.fetchComponent(this.props); + } + + componentWillReceiveProps(nextProps: Props) { + const { component } = this.state; + const componentChanged = + !component || + nextProps.rootComponent.key !== component.key || + nextProps.selected !== component.key; + if (componentChanged || nextProps.domain !== this.props.domain) { + this.fetchComponent(nextProps); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchComponent = ({ rootComponent, selected }: Props) => { + if (!selected || rootComponent.key === selected) { + this.setState({ component: rootComponent }); + this.updateLoading({ component: false }); + return; + } + this.updateLoading({ component: true }); + getComponentShow(selected).then( + ({ component }) => { + if (this.mounted) { + this.setState({ component }); + this.updateLoading({ component: false }); + } + }, + () => this.updateLoading({ component: false }) + ); + }; + + updateLoading = (loading: { [string]: boolean }) => { + if (this.mounted) { + this.setState(state => ({ loading: { ...state.loading, ...loading } })); + } + }; + + updateSelected = (component: string) => + this.props.updateQuery({ + selected: component !== this.props.rootComponent.key ? component : null + }); + + render() { + if (!this.state.component) { + return null; + } + + return ( + <MeasureOverview + className={this.props.className} + component={this.state.component} + currentUser={this.props.currentUser} + domain={this.props.domain} + loading={this.state.loading.component || this.state.loading.bubbles} + leakPeriod={this.props.leakPeriod} + metrics={this.props.metrics} + rootComponent={this.props.rootComponent} + updateLoading={this.updateLoading} + updateSelected={this.updateSelected} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/PageActions.js b/server/sonar-web/src/main/js/apps/component-measures/components/PageActions.js index 297d351d79d..3ac96ea7a8b 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/PageActions.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/PageActions.js @@ -29,7 +29,7 @@ type Props = {| loading: boolean, isFile: ?boolean, paging: ?Paging, - view: string + view?: string |}; export default class PageActions extends React.PureComponent { diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.js b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.js index 7561113bd27..7df3340638f 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.js @@ -54,3 +54,15 @@ it('should render correctly', () => { wrapper.setState({ loading: false }); expect(wrapper).toMatchSnapshot(); }); + +it('should render a measure overview', () => { + const wrapper = shallow( + <App + {...PROPS} + location={{ pathname: '/component_measures', query: { metric: 'Reliability' } }} + /> + ); + expect(wrapper.find('.spinner')).toHaveLength(1); + wrapper.setState({ loading: false }); + expect(wrapper.find('MeasureOverviewContainer')).toHaveLength(1); +}); diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.js b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.js new file mode 100644 index 00000000000..495a3fc3158 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.js @@ -0,0 +1,151 @@ +/* + * 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 EmptyResult from './EmptyResult'; +import OriginalBubbleChart from '../../../components/charts/BubbleChart'; +import { formatMeasure, isDiffMetric } from '../../../helpers/measures'; +import { + getLocalizedMetricDomain, + getLocalizedMetricName, + translateWithParameters +} from '../../../helpers/l10n'; +import { bubbles } from '../config/bubbles'; +import type { Component, ComponentEnhanced } from '../types'; +import type { Metric } from '../../../store/metrics/actions'; + +const HEIGHT = 500; + +type Props = {| + component: Component, + components: Array<ComponentEnhanced>, + domain: string, + metrics: { [string]: Metric }, + updateSelected: string => void +|}; + +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) { + return Number(isDiffMetric(metric.key) ? measure.leak : measure.value); + } + }; + + getTooltip( + componentName: string, + x: number, + y: number, + size: number, + xMetric: Metric, + yMetric: Metric, + sizeMetric: 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>`; + } + + handleBubbleClick = (component: ComponentEnhanced) => this.props.updateSelected(component.key); + + renderBubbleChart(xMetric: Metric, yMetric: Metric, sizeMetric: 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); + if ((!x && x !== 0) || (!y && y !== 0) || (!size && size !== 0)) { + return null; + } + return { + x, + y, + size, + link: component, + tooltip: this.getTooltip(component.name, x, y, size, xMetric, yMetric, sizeMetric) + }; + }) + .filter(Boolean); + + const formatXTick = tick => formatMeasure(tick, xMetric.type); + const formatYTick = tick => formatMeasure(tick, yMetric.type); + + return ( + <OriginalBubbleChart + items={items} + height={HEIGHT} + padding={[25, 60, 50, 60]} + formatXTick={formatXTick} + formatYTick={formatYTick} + onBubbleClick={this.handleBubbleClick} + /> + ); + } + + render() { + if (this.props.components.length <= 0) { + return <EmptyResult />; + } + + const { xMetric, yMetric, sizeMetric } = this.getBubbleMetrics(this.props); + 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"> + {translateWithParameters( + 'component_measures.legend.size_x', + getLocalizedMetricName(sizeMetric) + )} + </span> + </div> + <div> + {this.renderBubbleChart(xMetric, yMetric, sizeMetric)} + </div> + <div className="measure-details-bubble-chart-axis x"> + {getLocalizedMetricName(xMetric)} + </div> + <div className="measure-details-bubble-chart-axis y"> + {getLocalizedMetricName(yMetric)} + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.js b/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.js index ccc417f5d30..45be86a7f1d 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.js +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.js @@ -20,7 +20,7 @@ // @flow import React from 'react'; import ComponentsListRow from './ComponentsListRow'; -import EmptyComponentsList from './EmptyComponentsList'; +import EmptyResult from './EmptyResult'; import { complementary } from '../config/complementary'; import { getLocalizedMetricName } from '../../../helpers/l10n'; import type { Component } from '../types'; @@ -42,7 +42,7 @@ export default function ComponentsList({ selectedComponent }: Props) { if (!components.length) { - return <EmptyComponentsList />; + return <EmptyResult />; } const otherMetrics = (complementary[metric.key] || []).map(key => metrics[key]); diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/EmptyComponentsList.js b/server/sonar-web/src/main/js/apps/component-measures/drilldown/EmptyResult.js index 01f19c0bff5..3b237ad7dd8 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/EmptyComponentsList.js +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/EmptyResult.js @@ -21,7 +21,7 @@ import React from 'react'; import { translate } from '../../../helpers/l10n'; -export default function EmptyComponentsList() { +export default function EmptyResult() { return ( <div className="note"> {translate('no_results')} diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.js b/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.js index 529e95d25c4..5555bb37041 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.js +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.js @@ -64,7 +64,6 @@ export default class TreeMapView extends React.PureComponent { 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) @@ -74,7 +73,6 @@ export default class TreeMapView extends React.PureComponent { ? sizeMeasure.leak : sizeMeasure.value; if (sizeValue == null) { - // $FlowFixMe Null values are filtered just after return null; } return { @@ -93,7 +91,7 @@ export default class TreeMapView extends React.PureComponent { link: getComponentUrl(component.key) }; }) - .filter(component => component != null); + .filter(Boolean); }; getLevelColorScale = () => diff --git a/server/sonar-web/src/main/js/apps/component-measures/style.css b/server/sonar-web/src/main/js/apps/component-measures/style.css index 4d41d32f2f3..72834db9921 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/style.css +++ b/server/sonar-web/src/main/js/apps/component-measures/style.css @@ -93,3 +93,43 @@ .measure-favorite svg { vertical-align: middle; } + +.measure-details-bubble-chart { + position: relative; + padding: 0 0 30px 60px; + border: 1px solid #e6e6e6; + background-color: #fff; +} + +.measure-details-bubble-chart-header { + padding: 16px; + margin-left: -60px; + border-bottom: 1px solid #e6e6e6; +} + +.measure-details-bubble-chart-legend { + position: absolute; + width: 100%; + left: 0; + text-align: center; +} + +.measure-details-bubble-chart-axis { + position: absolute; + color: #777; + font-size: 12px; +} + +.measure-details-bubble-chart-axis.x { + left: 50%; + bottom: 10px; + width: 500px; + margin-left: -250px; + text-align: center; +} + +.measure-details-bubble-chart-axis.y { + top: 50%; + left: -20px; + transform: rotate(-90deg); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/utils.js b/server/sonar-web/src/main/js/apps/component-measures/utils.js index 5f00fa61bf7..8d672b97def 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/utils.js +++ b/server/sonar-web/src/main/js/apps/component-measures/utils.js @@ -74,11 +74,12 @@ export function sortMeasures( export const enhanceComponent = ( component: Component, - metric: Metric, + 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); + // $FlowFixMe metric can't be null since there is a guard for it + const measure = metric && enhancedMeasures.find(measure => measure.metric.key === metric.key); const value = measure ? measure.value : null; const leak = measure ? measure.leak : null; return { ...component, value, leak, measures: enhancedMeasures }; |