diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-07-27 17:21:25 +0200 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-08-14 11:44:44 +0200 |
commit | d7b669175e4e40341f6f1553ebe8ed84a9980ce2 (patch) | |
tree | c631f0dac9d40e88042d4d2ca8f4643b9f780134 /server/sonar-web | |
parent | a0ea568244db7b77b165c7ecf7dca83620f8edbd (diff) | |
download | sonarqube-d7b669175e4e40341f6f1553ebe8ed84a9980ce2.tar.gz sonarqube-d7b669175e4e40341f6f1553ebe8ed84a9980ce2.zip |
SONAR-9608 SONAR-9634 Create the measure list view
Diffstat (limited to 'server/sonar-web')
30 files changed, 1473 insertions, 123 deletions
diff --git a/server/sonar-web/src/main/js/apps/component-measures-old/details/MeasureDetailsHeader.js b/server/sonar-web/src/main/js/apps/component-measures-old/details/MeasureDetailsHeader.js index f2281c24eba..8a6254d140b 100644 --- a/server/sonar-web/src/main/js/apps/component-measures-old/details/MeasureDetailsHeader.js +++ b/server/sonar-web/src/main/js/apps/component-measures-old/details/MeasureDetailsHeader.js @@ -25,7 +25,7 @@ import LeakPeriodLegend from '../components/LeakPeriodLegend'; import IssueTypeIcon from '../../../components/ui/IssueTypeIcon'; import HistoryIcon from '../../../components/icons-components/HistoryIcon'; import Tooltip from '../../../components/controls/Tooltip'; -import { ComplexityDistribution } from '../../../components/shared/complexity-distribution'; +import ComplexityDistribution from '../../../components/shared/ComplexityDistribution'; import { isDiffMetric } from '../../../helpers/measures'; import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin'; import { getComponentMeasureHistory } from '../../../helpers/urls'; diff --git a/server/sonar-web/src/main/js/apps/component-measures/__tests__/utils-test.js b/server/sonar-web/src/main/js/apps/component-measures/__tests__/utils-test.js index 62eaf3c7a56..00be69c8786 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/__tests__/utils-test.js +++ b/server/sonar-web/src/main/js/apps/component-measures/__tests__/utils-test.js @@ -92,30 +92,32 @@ describe('groupByDomains', () => { describe('parseQuery', () => { it('should correctly parse the url query', () => { - expect(utils.parseQuery({})).toEqual({ metric: '', view: utils.DEFAULT_VIEW }); - expect(utils.parseQuery({ metric: 'foo', view: 'tree' })).toEqual({ + expect(utils.parseQuery({})).toEqual({ metric: '', selected: '', view: utils.DEFAULT_VIEW }); + expect(utils.parseQuery({ metric: 'foo', selected: 'bar', view: 'tree' })).toEqual({ metric: 'foo', + selected: 'bar', view: 'tree' }); }); it('should be memoized', () => { - const query = { metric: 'foo', view: 'tree' }; + const query = { metric: 'foo', selected: 'bar', view: 'tree' }; expect(utils.parseQuery(query)).toBe(utils.parseQuery(query)); }); }); describe('serializeQuery', () => { it('should correctly serialize the query', () => { - expect(utils.serializeQuery({ metric: '', view: 'list' })).toEqual({}); - expect(utils.serializeQuery({ metric: 'foo', view: 'tree' })).toEqual({ + expect(utils.serializeQuery({ metric: '', selected: '', view: 'list' })).toEqual({}); + expect(utils.serializeQuery({ metric: 'foo', selected: 'bar', view: 'tree' })).toEqual({ metric: 'foo', + selected: 'bar', view: 'tree' }); }); it('should be memoized', () => { - const query = { metric: 'foo', view: 'tree' }; + const query = { metric: 'foo', selected: 'bar', view: 'tree' }; expect(utils.serializeQuery(query)).toBe(utils.serializeQuery(query)); }); }); 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 02d5b590ef4..7b9ac879874 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 @@ -20,12 +20,13 @@ // @flow import React from 'react'; import Helmet from 'react-helmet'; +import MeasureContent from './MeasureContent'; import Sidebar from '../sidebar/Sidebar'; import { parseQuery, serializeQuery } from '../utils'; import { translate } from '../../../helpers/l10n'; import type { Component, Query, Period } from '../types'; import type { RawQuery } from '../../../helpers/query'; -import type { Metrics } from '../../../store/metrics/actions'; +import type { Metric } from '../../../store/metrics/actions'; import type { MeasureEnhanced } from '../../../components/measure/types'; import '../style.css'; @@ -34,10 +35,11 @@ type Props = {| location: { pathname: string, query: RawQuery }, fetchMeasures: ( Component, - Metrics - ) => Promise<{ measures: Array<MeasureEnhanced>, periods: Array<Period> }>, + Array<string> + ) => Promise<{ component: Component, measures: Array<MeasureEnhanced>, leakPeriod: ?Period }>, fetchMetrics: () => void, - metrics: Metrics, + metrics: { [string]: Metric }, + metricsKey: Array<string>, router: { push: ({ pathname: string, query?: RawQuery }) => void } @@ -46,7 +48,7 @@ type Props = {| type State = {| loading: boolean, measures: Array<MeasureEnhanced>, - periods: Array<Period> + leakPeriod: ?Period |}; export default class App extends React.PureComponent { @@ -59,7 +61,7 @@ export default class App extends React.PureComponent { this.state = { loading: true, measures: [], - periods: [] + leakPeriod: null }; } @@ -83,12 +85,27 @@ export default class App extends React.PureComponent { } } - fetchMeasures = ({ component, fetchMeasures, metrics }: Props) => { + componentWillUnmount() { + this.mounted = false; + const footer = document.getElementById('footer'); + if (footer) { + footer.classList.remove('search-navigator-footer'); + } + } + + fetchMeasures = ({ component, fetchMeasures, metrics, metricsKey }: Props) => { this.setState({ loading: true }); - fetchMeasures(component, metrics).then( - ({ measures, periods }) => { + const filterdKeys = metricsKey.filter( + key => !metrics[key].hidden && !['DATA', 'DISTRIB'].includes(metrics[key].type) + ); + fetchMeasures(component.key, filterdKeys).then( + ({ measures, leakPeriod }) => { if (this.mounted) { - this.setState({ loading: false, measures, periods }); + this.setState({ + loading: false, + leakPeriod, + measures: measures.filter(measure => measure.value != null || measure.leak != null) + }); } }, () => this.setState({ loading: false }) @@ -110,10 +127,12 @@ export default class App extends React.PureComponent { }; render() { - if (this.state.loading) { + const isLoading = this.state.loading || this.props.metricsKey.length <= 0; + if (isLoading) { return <i className="spinner spinner-margin" />; } const query = parseQuery(this.props.location.query); + const metric = this.props.metrics[query.metric]; return ( <div className="layout-page" id="component-measures"> <Helmet title={translate('layout.measures')} /> @@ -132,15 +151,17 @@ export default class App extends React.PureComponent { </div> </div> - <div className="layout-page-main"> - <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">Page Actions</div> - </div> - </div> - - <div className="layout-page-main-inner">Main</div> - </div> + {metric != null && + <MeasureContent + className="layout-page-main-inner" + rootComponent={this.props.component} + fetchMeasures={this.props.fetchMeasures} + leakPeriod={this.state.leakPeriod} + metric={metric} + metrics={this.props.metrics} + selected={query.selected} + updateQuery={this.updateQuery} + />} </div> ); } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/AppContainer.js b/server/sonar-web/src/main/js/apps/component-measures/components/AppContainer.js index 063a5bc2124..f8979c7eea7 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/AppContainer.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/AppContainer.js @@ -22,21 +22,27 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import App from './App'; import throwGlobalError from '../../../app/utils/throwGlobalError'; -import { getComponent, getMetrics, getMetricByKey } from '../../../store/rootReducer'; +import { + getComponent, + getMetrics, + getMetricByKey, + getMetricsKey +} from '../../../store/rootReducer'; import { fetchMetrics } from '../../../store/rootActions'; import { getMeasuresAndMeta } from '../../../api/measures'; -import { getLeakValue } from '../utils'; -import type { Component } from '../types'; -import type { Metrics } from '../../../store/metrics/actions'; +import { getLeakPeriod } from '../../../helpers/periods'; +import { enhanceMeasure } from '../../../components/measure/utils'; +import type { Component, Period } from '../types'; import type { Measure, MeasureEnhanced } from '../../../components/measure/types'; const mapStateToProps = (state, ownProps) => ({ component: getComponent(state, ownProps.location.query.id), - metrics: getMetrics(state) + metrics: getMetrics(state), + metricsKey: getMetricsKey(state) }); -const banQualityGate = (component: Component, measures: Array<Measure>): Array<Measure> => { - let newMeasures = [...measures]; +const banQualityGate = (component: Component): Array<Measure> => { + let newMeasures = [...component.measures]; if (!['VW', 'SVW', 'APP'].includes(component.qualifier)) { newMeasures = newMeasures.filter(measure => measure.metric !== 'alert_status'); } @@ -49,36 +55,23 @@ const banQualityGate = (component: Component, measures: Array<Measure>): Array<M return newMeasures; }; -const fetchMeasures = (component: Component, metrics: Metrics) => ( +const fetchMeasures = (component: string, metrics: Array<string>) => ( dispatch, getState -): Promise<Array<MeasureEnhanced>> => { - const metricKeys = metrics.filter(key => { - const metric = getMetricByKey(getState(), key); - return !metric.hidden && !['DATA', 'DISTRIB'].includes(metric.type); - }); - - if (metricKeys.length <= 0) { - return Promise.resolve([]); +): Promise<{ component: Component, measures: Array<MeasureEnhanced>, leakPeriod: ?Period }> => { + if (metrics.length <= 0) { + return Promise.resolve({ component: {}, measures: [], leakPeriod: null }); } - return getMeasuresAndMeta(component.key, metricKeys, { additionalFields: 'periods' }).then(r => { - const measures: Array<MeasureEnhanced> = banQualityGate(component, r.component.measures) - .map(measure => { - const metric = getMetricByKey(getState(), measure.metric); - const leak = getLeakValue(measure); - return { value: measure.value, periods: measure.periods, metric, leak }; - }) - .filter(measure => { - const hasValue = measure.value != null; - const hasLeakValue = measure.leak != null; - return hasValue || hasLeakValue; - }); + return getMeasuresAndMeta(component, metrics, { additionalFields: 'periods' }).then(r => { + const measures: Array<MeasureEnhanced> = banQualityGate(r.component).map(measure => + enhanceMeasure(measure, getMetricByKey(getState(), measure.metric)) + ); const newBugs = measures.find(measure => measure.metric.key === 'new_bugs'); const applicationPeriods = newBugs ? [{ index: 1 }] : []; - const periods = component.qualifier === 'APP' ? applicationPeriods : r.periods; - return { measures, periods }; + const periods = r.component.qualifier === 'APP' ? applicationPeriods : r.periods; + return { component: r.component, measures, leakPeriod: getLeakPeriod(periods) }; }, throwGlobalError); }; 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 new file mode 100644 index 00000000000..1d36e84a078 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/LeakPeriodLegend.js @@ -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 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 +}: { + component: Component, + period: Period +}) { + if (component.qualifier === 'APP') { + return ( + <div className="domain-measures-leak-header"> + {translate('issues.leak_period')} + </div> + ); + } + + const label = ( + <div className="domain-measures-leak-header"> + {translateWithParameters('overview.leak_period_x', getPeriodLabel(period))} + </div> + ); + + if (period.mode === 'days') { + return label; + } + + const date = getPeriodDate(period); + const fromNow = moment(date).fromNow(); + const tooltip = fromNow + ', ' + moment(date).format('LL'); + return ( + <Tooltip placement="left" overlay={tooltip}> + {label} + </Tooltip> + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.js new file mode 100644 index 00000000000..68bc0bb3eac --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.js @@ -0,0 +1,160 @@ +/* + * 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 DeferredSpinner from '../../../components/common/DeferredSpinner'; +import ListView from './drilldown/ListView'; +import MeasureHeader from './MeasureHeader'; +import MetricNotFound from './MetricNotFound'; +import type { Component, Period, Query } from '../types'; +import type { MeasureEnhanced } from '../../../components/measure/types'; +import type { Metric } from '../../../store/metrics/actions'; + +type Props = { + className?: string, + rootComponent: Component, + fetchMeasures: ( + Component, + Array<string> + ) => Promise<{ component: Component, measures: Array<MeasureEnhanced> }>, + leakPeriod?: Period, + metric: Metric, + metrics: { [string]: Metric }, + selected: ?string, + updateQuery: Query => void +}; + +type State = { + component: ?Component, + loading: { + measure: boolean, + components: boolean + }, + measure: ?MeasureEnhanced, + secondaryMeasure: ?MeasureEnhanced +}; + +export default class MeasureContent extends React.PureComponent { + mounted: boolean; + props: Props; + state: State = { + component: null, + loading: { + measure: false, + components: false + }, + measure: null, + secondaryMeasure: null + }; + + componentDidMount() { + this.mounted = true; + this.fetchMeasure(this.props); + } + + componentWillReceiveProps(nextProps: Props) { + const { component } = this.state; + const componentChanged = + !component || + nextProps.rootComponent.key !== component.key || + nextProps.selected !== component.key; + if (componentChanged || nextProps.metric !== this.props.metric) { + this.fetchMeasure(nextProps); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchMeasure = ({ rootComponent, fetchMeasures, metric, selected }: Props) => { + this.updateLoading({ measure: true }); + + const metricKeys = [metric.key]; + if (metric.key === 'ncloc') { + metricKeys.push('ncloc_language_distribution'); + } else if (metric.key === 'function_complexity') { + metricKeys.push('function_complexity_distribution'); + } else if (metric.key === 'file_complexity') { + metricKeys.push('file_complexity_distribution'); + } + + fetchMeasures(selected || rootComponent.key, metricKeys).then( + ({ component, measures }) => { + if (this.mounted) { + const measure = measures.find(measure => measure.metric.key === metric.key); + const secondaryMeasure = measures.find(measure => measure.metric.key !== metric.key); + this.setState({ component, measure, secondaryMeasure }); + this.updateLoading({ measure: false }); + } + }, + () => this.updateLoading({ measure: false }) + ); + }; + + handleSelect = (component: Component) => this.props.updateQuery({ selected: component.key }); + + updateLoading = (loading: { [string]: boolean }) => { + if (this.mounted) { + this.setState(state => ({ loading: { ...state.loading, ...loading } })); + } + }; + + render() { + const { metric } = this.props; + const { loading, measure } = this.state; + + return ( + <div className="layout-page-main"> + <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"> + Page Actions + <DeferredSpinner + className="pull-right" + loading={loading.measure || loading.components} + /> + </div> + </div> + </div> + {metric != null && measure != null + ? <div className="layout-page-main-inner"> + <MeasureHeader + component={this.state.component} + leakPeriod={this.props.leakPeriod} + measure={measure} + secondaryMeasure={this.state.secondaryMeasure} + /> + <ListView + component={this.state.component} + handleSelect={this.handleSelect} + leakPeriod={this.props.leakPeriod} + loading={loading.components} + metric={metric} + metrics={this.props.metrics} + selectedComponent={this.props.selected} + updateLoading={this.updateLoading} + /> + </div> + : <MetricNotFound className="layout-page-main-inner" />} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.js new file mode 100644 index 00000000000..c58748cda20 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.js @@ -0,0 +1,86 @@ +/* + * 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 { Link } from 'react-router'; +import ComplexityDistribution from '../../../components/shared/ComplexityDistribution'; +import HistoryIcon from '../../../components/icons-components/HistoryIcon'; +import IssueTypeIcon from '../../../components/ui/IssueTypeIcon'; +import LanguageDistribution from '../../../components/charts/LanguageDistribution'; +import LeakPeriodLegend from './LeakPeriodLegend'; +import Measure from '../../../components/measure/Measure'; +import Tooltip from '../../../components/controls/Tooltip'; +import { getLocalizedMetricName, translate } from '../../../helpers/l10n'; +import { getComponentMeasureHistory } from '../../../helpers/urls'; +import { isDiffMetric } from '../../../helpers/measures'; +import type { Component, Period } from '../types'; +import type { MeasureEnhanced } from '../../../components/measure/types'; + +type Props = { + component: Component, + leakPeriod?: Period, + measure: MeasureEnhanced, + secondaryMeasure: ?MeasureEnhanced +}; + +export default function MeasureHeader({ component, leakPeriod, measure, secondaryMeasure }: Props) { + const metric = measure.metric; + const isDiff = isDiffMetric(metric.key); + return ( + <div className="measure-details-header big-spacer-bottom"> + <div className="measure-details-metric"> + <IssueTypeIcon query={metric.key} className="little-spacer-right text-text-bottom" /> + {getLocalizedMetricName(metric)} + <span className="measure-details-value spacer-left"> + <strong> + {isDiff + ? <Measure className="domain-measures-leak" measure={measure} metric={metric} /> + : <Measure measure={measure} metric={metric} />} + </strong> + </span> + {!isDiff && + <Tooltip placement="right" overlay={translate('component_measures.show_metric_history')}> + <Link + className="js-show-history spacer-left button button-small button-compact" + to={getComponentMeasureHistory(component.key, metric.key)}> + <HistoryIcon /> + </Link> + </Tooltip>} + {secondaryMeasure && + secondaryMeasure.metric.key === 'ncloc_language_distribution' && + <div className="measure-details-secondary"> + <LanguageDistribution distribution={secondaryMeasure.value} /> + </div>} + + {secondaryMeasure && + secondaryMeasure.metric.key === 'function_complexity_distribution' && + <div className="measure-details-secondary"> + <ComplexityDistribution distribution={secondaryMeasure.value} of="function" /> + </div>} + + {secondaryMeasure && + secondaryMeasure.metric.key === 'file_complexity_distribution' && + <div className="measure-details-secondary"> + <ComplexityDistribution distribution={secondaryMeasure.value} of="file" /> + </div>} + </div> + {leakPeriod != null && <LeakPeriodLegend component={component} period={leakPeriod} />} + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MetricNotFound.js b/server/sonar-web/src/main/js/apps/component-measures/components/MetricNotFound.js new file mode 100644 index 00000000000..2dff010b0a0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MetricNotFound.js @@ -0,0 +1,32 @@ +/* + * 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 { translate } from '../../../helpers/l10n'; + +export default function MetricNotFound({ className }: { className?: string }) { + return ( + <div className={className}> + <div className="alert alert-danger"> + {translate('component_measures.not_found')} + </div> + </div> + ); +} 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 a17ab39c8f1..7561113bd27 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 @@ -21,24 +21,30 @@ import React from 'react'; import { shallow } from 'enzyme'; import App from '../App'; -const METRICS = [ - { key: 'lines_to_cover', type: 'INT', name: 'Lines to Cover', domain: 'Coverage' }, - { key: 'coverage', type: 'PERCENT', name: 'Coverage', domain: 'Coverage' }, - { +const METRICS = { + lines_to_cover: { + key: 'lines_to_cover', + type: 'INT', + name: 'Lines to Cover', + domain: 'Coverage' + }, + coverage: { key: 'coverage', type: 'PERCENT', name: 'Coverage', domain: 'Coverage' }, + duplicated_lines_density: { key: 'duplicated_lines_density', type: 'PERCENT', name: 'Duplicated Lines (%)', domain: 'Duplications' }, - { key: 'new_bugs', type: 'INT', name: 'New Bugs', domain: 'Reliability' } -]; + new_bugs: { key: 'new_bugs', type: 'INT', name: 'New Bugs', domain: 'Reliability' } +}; const PROPS = { component: { key: 'foo' }, - location: { pathname: '/component_measures', query: {} }, + location: { pathname: '/component_measures', query: { metric: 'coverage' } }, fetchMeasures: () => {}, fetchMetrics: () => {}, metrics: METRICS, + metricsKey: ['lines_to_cover', 'coverage', 'duplicated_lines_density', 'new_bugs'], router: { push: () => {} } }; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/LeakPeriodLegend-test.js b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/LeakPeriodLegend-test.js new file mode 100644 index 00000000000..4f7fce011d7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/LeakPeriodLegend-test.js @@ -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. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import LeakPeriodLegend from '../LeakPeriodLegend'; + +const PROJECT = { + key: 'foo', + qualifier: 'TRK' +}; + +const APP = { + key: 'bar', + qualifier: 'APP' +}; + +const PERIOD = { + date: '2017-05-16T13:50:02+0200', + index: 1, + mode: 'previous_version', + parameter: '6,4' +}; + +const PERIOD_DAYS = { + date: '2017-05-16T13:50:02+0200', + index: 1, + mode: 'days', + parameter: '18' +}; + +jest.mock('moment', () => () => ({ + format: () => 'March 1, 2017 9:36 AM', + fromNow: () => 'a month ago', + toDate: () => 'date' +})); + +it('should render correctly', () => { + expect(shallow(<LeakPeriodLegend component={PROJECT} period={PERIOD} />)).toMatchSnapshot(); + expect(shallow(<LeakPeriodLegend component={PROJECT} period={PERIOD_DAYS} />)).toMatchSnapshot(); +}); + +it('should render correctly for APP', () => { + expect(shallow(<LeakPeriodLegend component={APP} period={PERIOD} />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/MeasureHeader-test.js b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/MeasureHeader-test.js new file mode 100644 index 00000000000..8914cf0a86e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/MeasureHeader-test.js @@ -0,0 +1,78 @@ +/* + * 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 MeasureHeader from '../MeasureHeader'; + +const MEASURE = { + value: '3.0', + periods: [{ index: 1, value: '0.0' }], + metric: { + key: 'reliability_rating', + type: 'RATING', + name: 'Reliability Rating' + }, + leak: '0.0' +}; + +const LEAK_MEASURE = { + periods: [{ index: 1, value: '3.0' }], + metric: { + key: 'new_reliability_rating', + type: 'RATING', + name: 'Reliability Rating on New Code' + }, + leak: '3.0' +}; + +const SECONDARY = { + value: 'java=175123;js=26382', + metric: { + key: 'ncloc_language_distribution', + type: 'DATA', + name: 'Lines of Code Per Language' + }, + leak: null +}; + +const PROPS = { + component: { key: 'foo' }, + leakPeriod: { + date: '2017-05-16T13:50:02+0200', + index: 1, + mode: 'previous_version', + parameter: '6,4' + }, + measure: MEASURE, + secondaryMeasure: null +}; + +it('should render correctly', () => { + expect(shallow(<MeasureHeader {...PROPS} />)).toMatchSnapshot(); +}); + +it('should render correctly for leak', () => { + expect(shallow(<MeasureHeader {...PROPS} measure={LEAK_MEASURE} />)).toMatchSnapshot(); +}); + +it('should display secondary measure too', () => { + const wrapper = shallow(<MeasureHeader {...PROPS} secondaryMeasure={SECONDARY} />); + expect(wrapper.find('LanguageDistribution')).toHaveLength(1); +}); diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.js.snap b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.js.snap index c74cb34eff9..1eeed263913 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.js.snap +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.js.snap @@ -28,34 +28,60 @@ exports[`should render correctly 1`] = ` > <Sidebar measures={Array []} - selectedMetric="" + selectedMetric="coverage" updateQuery={[Function]} /> </div> </div> </div> </div> - <div - className="layout-page-main" - > - <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" - > - Page Actions - </div> - </div> - </div> - <div - className="layout-page-main-inner" - > - Main - </div> - </div> + <MeasureContent + className="layout-page-main-inner" + fetchMeasures={[Function]} + leakPeriod={null} + metric={ + Object { + "domain": "Coverage", + "key": "coverage", + "name": "Coverage", + "type": "PERCENT", + } + } + metrics={ + Object { + "coverage": Object { + "domain": "Coverage", + "key": "coverage", + "name": "Coverage", + "type": "PERCENT", + }, + "duplicated_lines_density": Object { + "domain": "Duplications", + "key": "duplicated_lines_density", + "name": "Duplicated Lines (%)", + "type": "PERCENT", + }, + "lines_to_cover": Object { + "domain": "Coverage", + "key": "lines_to_cover", + "name": "Lines to Cover", + "type": "INT", + }, + "new_bugs": Object { + "domain": "Reliability", + "key": "new_bugs", + "name": "New Bugs", + "type": "INT", + }, + } + } + rootComponent={ + Object { + "key": "foo", + } + } + selected="" + updateQuery={[Function]} + /> </div> `; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/LeakPeriodLegend-test.js.snap b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/LeakPeriodLegend-test.js.snap new file mode 100644 index 00000000000..0f0e328d85e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/LeakPeriodLegend-test.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Tooltip + overlay="a month ago, March 1, 2017 9:36 AM" + placement="left" +> + <div + className="domain-measures-leak-header" + > + overview.leak_period_x.overview.period.previous_version.6,4 + </div> +</Tooltip> +`; + +exports[`should render correctly 2`] = ` +<div + className="domain-measures-leak-header" +> + overview.leak_period_x.overview.period.days.18 +</div> +`; + +exports[`should render correctly for APP 1`] = ` +<div + className="domain-measures-leak-header" +> + issues.leak_period +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/MeasureHeader-test.js.snap b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/MeasureHeader-test.js.snap new file mode 100644 index 00000000000..3e66e8fa4fc --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/MeasureHeader-test.js.snap @@ -0,0 +1,149 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<div + className="measure-details-header big-spacer-bottom" +> + <div + className="measure-details-metric" + > + <IssueTypeIcon + className="little-spacer-right text-text-bottom" + query="reliability_rating" + /> + Reliability Rating + <span + className="measure-details-value spacer-left" + > + <strong> + <Measure + measure={ + Object { + "leak": "0.0", + "metric": Object { + "key": "reliability_rating", + "name": "Reliability Rating", + "type": "RATING", + }, + "periods": Array [ + Object { + "index": 1, + "value": "0.0", + }, + ], + "value": "3.0", + } + } + metric={ + Object { + "key": "reliability_rating", + "name": "Reliability Rating", + "type": "RATING", + } + } + /> + </strong> + </span> + <Tooltip + overlay="component_measures.show_metric_history" + placement="right" + > + <Link + className="js-show-history spacer-left button button-small button-compact" + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/project/activity", + "query": Object { + "custom_metrics": "reliability_rating", + "graph": "custom", + "id": "foo", + }, + } + } + > + <IconHistory /> + </Link> + </Tooltip> + </div> + <LeakPeriodLegend + component={ + Object { + "key": "foo", + } + } + period={ + Object { + "date": "2017-05-16T13:50:02+0200", + "index": 1, + "mode": "previous_version", + "parameter": "6,4", + } + } + /> +</div> +`; + +exports[`should render correctly for leak 1`] = ` +<div + className="measure-details-header big-spacer-bottom" +> + <div + className="measure-details-metric" + > + <IssueTypeIcon + className="little-spacer-right text-text-bottom" + query="new_reliability_rating" + /> + Reliability Rating on New Code + <span + className="measure-details-value spacer-left" + > + <strong> + <Measure + className="domain-measures-leak" + measure={ + Object { + "leak": "3.0", + "metric": Object { + "key": "new_reliability_rating", + "name": "Reliability Rating on New Code", + "type": "RATING", + }, + "periods": Array [ + Object { + "index": 1, + "value": "3.0", + }, + ], + } + } + metric={ + Object { + "key": "new_reliability_rating", + "name": "Reliability Rating on New Code", + "type": "RATING", + } + } + /> + </strong> + </span> + </div> + <LeakPeriodLegend + component={ + Object { + "key": "foo", + } + } + period={ + Object { + "date": "2017-05-16T13:50:02+0200", + "index": 1, + "mode": "previous_version", + "parameter": "6,4", + } + } + /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentCell.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentCell.js new file mode 100644 index 00000000000..a3b54e1955e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentCell.js @@ -0,0 +1,106 @@ +/* + * 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 QualifierIcon from '../../../../components/shared/QualifierIcon'; +import { splitPath } from '../../../../helpers/path'; +import { getComponentUrl } from '../../../../helpers/urls'; +import type { Component } from '../../types'; + +type Props = { + component: Component, + isSelected: boolean, + onClick: Component => void +}; + +export default class ComponentCell extends React.PureComponent { + props: Props; + + handleClick = (e: MouseEvent) => { + const isLeftClickEvent = e.button === 0; + const isModifiedEvent = !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); + + if (isLeftClickEvent && !isModifiedEvent) { + e.preventDefault(); + this.props.onClick(); + } + }; + + renderInner() { + const { component } = this.props; + let head = ''; + let tail = component.name; + + if (['DIR', 'FIL', 'UTS'].includes(component.qualifier)) { + const parts = splitPath(component.path); + head = parts.head; + tail = parts.tail; + } + return ( + <span title={component.refKey || component.key}> + <QualifierIcon qualifier={component.qualifier} /> + + {head.length > 0 && + <span className="note"> + {head}/ + </span>} + <span>{tail}</span> + </span> + ); + } + + render() { + const { component } = this.props; + const linkClassName = classNames('link-no-underline', { + selected: this.props.isSelected + }); + + return ( + <td style={{ maxWidth: 0 }}> + <div + style={{ + maxWidth: '100%', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis' + }}> + {component.refId == null || component.qualifier === 'DEV_PRJ' + ? <a + id={'component-measures-component-link-' + component.key} + className={linkClassName} + href={getComponentUrl(component.key)} + onClick={this.handleClick}> + {this.renderInner()} + </a> + : <a + id={'component-measures-component-link-' + component.key} + className={linkClassName} + href={getComponentUrl(component.refKey || component.key)}> + <span className="big-spacer-right"> + <i className="icon-detach" /> + </span> + {this.renderInner()} + </a>} + </div> + </td> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsList.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsList.js new file mode 100644 index 00000000000..ba8d42d84c9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsList.js @@ -0,0 +1,84 @@ +/* + * 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 ComponentsListRow from './ComponentsListRow'; +import EmptyComponentsList from './EmptyComponentsList'; +import { complementary } from '../../config/complementary'; +import { getLocalizedMetricName } from '../../../../helpers/l10n'; +import type { Component } from '../../types'; +import type { Metric } from '../../../../store/metrics/actions'; + +type Props = { + components: Array<Component>, + onClick: Component => void, + metric: Metric, + metrics: { [string]: Metric }, + selectedComponent?: ?string +}; + +export default function ComponentsList({ + components, + onClick, + metrics, + metric, + selectedComponent +}: Props) { + if (!components.length) { + return <EmptyComponentsList />; + } + + const otherMetrics = (complementary[metric.key] || []).map(key => metrics[key]); + return ( + <table className="data zebra zebra-hover"> + {otherMetrics.length > 0 && + <thead> + <tr> + <th> </th> + <th className="text-right"> + <span className="small"> + {getLocalizedMetricName(metric)} + </span> + </th> + {otherMetrics.map(metric => + <th key={metric.key} className="text-right"> + <span className="small"> + {getLocalizedMetricName(metric)} + </span> + </th> + )} + </tr> + </thead>} + + <tbody> + {components.map(component => + <ComponentsListRow + key={component.id} + component={component} + otherMetrics={otherMetrics} + isSelected={component.key === selectedComponent} + metric={metric} + onClick={onClick} + /> + )} + </tbody> + </table> + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsListRow.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsListRow.js new file mode 100644 index 00000000000..deab1a98453 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ComponentsListRow.js @@ -0,0 +1,70 @@ +/* + * 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 ComponentCell from './ComponentCell'; +import MeasureCell from './MeasureCell'; +import type { Component } from '../../types'; +import type { Metric } from '../../../../store/metrics/actions'; + +type Props = { + component: Component, + isSelected: boolean, + onClick: Component => void, + otherMetrics: Array<Metric>, + metric: Metric +}; + +export default class ComponentsListRow extends React.PureComponent { + props: Props; + + handleClick = () => this.props.onClick(this.props.component); + + 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} + /> + + <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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/EmptyComponentsList.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/EmptyComponentsList.js new file mode 100644 index 00000000000..bcdeee0e710 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/EmptyComponentsList.js @@ -0,0 +1,30 @@ +/* + * 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 { translate } from '../../../../helpers/l10n'; + +export default function EmptyComponentsList() { + return ( + <div className="note"> + {translate('no_results')} + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ListView.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ListView.js new file mode 100644 index 00000000000..d567f772759 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/ListView.js @@ -0,0 +1,193 @@ +/* + * 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 moment from 'moment'; +import ComponentsList from './ComponentsList'; +import ListFooter from '../../../../components/controls/ListFooter'; +import SourceViewer from '../../../../components/SourceViewer/SourceViewer'; +import { getComponentTree } from '../../../../api/components'; +import { complementary } from '../../config/complementary'; +import { isDiffMetric } from '../../../../helpers/measures'; +import { enhanceComponent } from '../../utils'; +import type { Component, ComponentEnhanced, Paging, Period } from '../../types'; +import type { Metric } from '../../../../store/metrics/actions'; + +type Props = { + component: Component, + handleSelect: Component => void, + leakPeriod?: Period, + loading: boolean, + metric: Metric, + metrics: { [string]: Metric }, + selectedComponent: ?string, + updateLoading: ({ [string]: boolean }) => void +}; + +type State = { + components: Array<ComponentEnhanced>, + metric: ?Metric, + paging?: Paging +}; + +export default class ListView extends React.PureComponent { + mounted: boolean; + props: Props; + state: State = { + components: [], + metric: null, + paging: null + }; + + componentDidMount() { + this.mounted = true; + this.fetchComponents(this.props); + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.component !== this.props.component || nextProps.metric !== this.props.metric) { + this.fetchComponents(nextProps); + } + } + + componentWillUnmount() { + 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 + }; + } else { + opts = { + ...opts, + s: 'metric,name' + }; + } + return { metricKeys, opts: { ...opts, ...options } }; + }; + + fetchComponents = ({ component, metric, selectedComponent }: Props) => { + if (selectedComponent) { + this.setState({ metric }); + return; + } + const { metricKeys, opts } = this.getComponentRequestParams(metric); + this.props.updateLoading({ components: true }); + getComponentTree('leaves', component.key, metricKeys, opts).then( + r => { + if (this.mounted) { + this.setState({ + components: r.components.map(component => enhanceComponent(component, metric)), + metric, + paging: r.paging + }); + } + this.props.updateLoading({ components: false }); + }, + () => this.props.updateLoading({ components: false }) + ); + }; + + fetchMoreComponents = () => { + const { component, metric } = this.props; + const { paging } = this.state; + if (!paging) { + return; + } + const { metricKeys, opts } = this.getComponentRequestParams(metric, { + p: paging.pageIndex + 1 + }); + this.props.updateLoading({ components: true }); + getComponentTree('leaves', component.key, metricKeys, opts).then( + r => { + if (this.mounted) { + this.setState(state => ({ + components: [ + ...state.components, + ...r.components.map(component => enhanceComponent(component, metric)) + ], + metric, + paging: r.paging + })); + } + this.props.updateLoading({ components: false }); + }, + () => this.props.updateLoading({ components: false }) + ); + }; + + render() { + const { components, metric, paging } = this.state; + if (metric == null) { + return null; + } + + const { leakPeriod, selectedComponent } = this.props; + if (selectedComponent) { + const leakPeriodDate = + isDiffMetric(metric.key) && leakPeriod != null ? moment(leakPeriod.date).toDate() : null; + + let filterLine; + if (leakPeriodDate != null) { + filterLine = line => { + if (line.scmDate) { + const scmDate = moment(line.scmDate).toDate(); + return scmDate >= leakPeriodDate; + } else { + return false; + } + }; + } + return ( + <div className="measure-details-viewer"> + <SourceViewer component={selectedComponent} filterLine={filterLine} /> + </div> + ); + } + + return ( + <div> + <ComponentsList + components={components} + metrics={this.props.metrics} + metric={metric} + onClick={this.props.handleSelect} + /> + {paging && + <ListFooter + count={components.length} + total={paging.total} + loadMore={this.fetchMoreComponents} + />} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/MeasureCell.js b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/MeasureCell.js new file mode 100644 index 00000000000..be6348e5105 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/drilldown/MeasureCell.js @@ -0,0 +1,39 @@ +/* + * 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 Measure from '../../../../components/measure/Measure'; +import type { Component } from '../../types'; +import type { Metric } from '../../../../store/metrics/actions'; + +type Props = { + component: Component, + metric: Metric +}; + +export default function MeasureCell({ component, metric }: Props) { + return ( + <td className="thin nowrap text-right"> + <span id={'component-measures-component-measure-' + component.key + '-' + metric.key}> + <Measure measure={{ metric, value: component.value, leak: component.leak }} /> + </span> + </td> + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.js b/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.js new file mode 100644 index 00000000000..3fd1e2a2055 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.js @@ -0,0 +1,27 @@ +/* + * 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 +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' }, + Coverage: { x: 'complexity', y: 'coverage', size: 'uncovered_lines' }, + Duplications: { x: 'ncloc', y: 'duplicated_lines', size: 'duplicated_blocks' } +}; diff --git a/server/sonar-web/src/main/js/apps/component-measures/config/complementary.js b/server/sonar-web/src/main/js/apps/component-measures/config/complementary.js new file mode 100644 index 00000000000..e50c2238aac --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/config/complementary.js @@ -0,0 +1,38 @@ +/* + * 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 +export const complementary = { + coverage: ['uncovered_lines', 'uncovered_conditions'], + line_coverage: ['uncovered_lines'], + branch_coverage: ['uncovered_conditions'], + uncovered_lines: ['line_coverage'], + uncovered_conditions: ['branch_coverage'], + + new_coverage: ['new_uncovered_lines', 'new_uncovered_conditions'], + new_line_coverage: ['new_uncovered_lines'], + new_branch_coverage: ['new_uncovered_conditions'], + new_uncovered_lines: ['new_line_coverage'], + new_uncovered_conditions: ['new_branch_coverage'], + + duplicated_lines_density: ['duplicated_lines'], + new_duplicated_lines_density: ['new_duplicated_lines'], + duplicated_lines: ['duplicated_lines_density'], + new_duplicated_lines: ['new_duplicated_lines_density'] +}; 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 fc294c55dee..1e3aa63a269 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 @@ -1,19 +1,45 @@ -.measures-domains-leak-header { - float: right; +.domain-measures-leak { background-color: #fbf3d5; border: 1px solid #eae3c7; - padding: 4px 10px; - white-space: nowrap; + padding: 4px 6px; } -.domain-measures-leak { - background-color: #fbf3d5; - border: 1px solid #eae3c7; +.facet .domain-measures-leak { padding: 4px 4px; margin: -5px -5px; } -.domain-measures-value .rating { +.domain-measures-leak-header { + background-color: #fbf3d5; + border: 1px solid #eae3c7; + padding: 4px 10px; + white-space: nowrap; +} + +.measure-details-header { + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; +} + +.measure-details-metric { + display: flex; + align-items: center; +} + +.measure-details-secondary { + display: inline-block; + width: 260px; + margin-left: 4px; +} + +.measure-details-secondary .bar-chart { + margin-top: -10px; +} + +.domain-measures-value .rating, +.measure-details-value .rating { width: 18px; height: 18px; line-height: 18px; @@ -21,8 +47,3 @@ margin-bottom: -2px; font-size: 12px; } - -.domain-measures-leak .rating { - margin-top: -3px; - margin-bottom: -3px; -} diff --git a/server/sonar-web/src/main/js/apps/component-measures/types.js b/server/sonar-web/src/main/js/apps/component-measures/types.js index 501e60ed0c1..340baf37de1 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/types.js +++ b/server/sonar-web/src/main/js/apps/component-measures/types.js @@ -17,7 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -export type Component = { +import type { Measure, MeasureEnhanced } from '../../components/measure/types'; + +type ComponentIntern = { isFavorite?: boolean, isRecentlyBrowsed?: boolean, key: string, @@ -28,9 +30,18 @@ export type Component = { qualifier: string }; -export type Query = { - metric: ?string, - view: string +export type Component = ComponentIntern & { measures?: Array<Measure> }; + +export type ComponentEnhanced = ComponentIntern & { + value?: ?string, + leak?: ?string, + measures: Array<MeasureEnhanced> +}; + +export type Paging = { + pageIndex: number, + pageSize: number, + total: number }; export type Period = { @@ -39,3 +50,9 @@ export type Period = { mode: string, parameter?: string }; + +export type Query = { + metric: ?string, + selected: ?string, + view: string +}; 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 2622792dcda..f6fcfdabc3d 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 @@ -22,9 +22,12 @@ import { groupBy, memoize, sortBy, toPairs } from 'lodash'; import { getLocalizedMetricName } from '../../helpers/l10n'; import { cleanQuery, parseAsString, serializeString } from '../../helpers/query'; import { domains } from './config/domains'; -import type { Query } from './types'; +import { bubbles } from './config/bubbles'; +import { enhanceMeasure } from '../../components/measure/utils'; +import type { Component, ComponentEnhanced, Query } from './types'; import type { RawQuery } from '../../helpers/query'; -import type { Measure, MeasureEnhanced } from '../../components/measure/types'; +import type { Metric } from '../../store/metrics/actions'; +import type { MeasureEnhanced } from '../../components/measure/types'; export const DEFAULT_VIEW = 'list'; const KNOWN_DOMAINS = [ @@ -69,13 +72,13 @@ export function sortMeasures( ]); } -export function getLeakValue(measure: ?Measure): ?string { - if (!measure || !measure.periods) { - return null; - } - const period = measure.periods.find(period => period.index === 1); - return period ? period.value : null; -} +export const enhanceComponent = (component: Component, metric: Metric): ComponentEnhanced => { + const enhancedMeasures = component.measures.map(measure => enhanceMeasure(measure, metric)); + const measure = 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 }; +}; export const groupByDomains = memoize((measures: Array<MeasureEnhanced>): Array<{ name: string, @@ -96,14 +99,18 @@ export const groupByDomains = memoize((measures: Array<MeasureEnhanced>): Array< ]); }); +export const hasBubbleChart = (domainName: string): boolean => bubbles[domainName] != null; + export const parseQuery = memoize((urlQuery: RawQuery): Query => ({ metric: parseAsString(urlQuery['metric']), + selected: parseAsString(urlQuery['selected']), view: parseAsString(urlQuery['view']) || DEFAULT_VIEW })); export const serializeQuery = memoize((query: Query): RawQuery => { return cleanQuery({ metric: serializeString(query.metric), + selected: serializeString(query.selected), view: query.view === DEFAULT_VIEW ? null : serializeString(query.view) }); }); diff --git a/server/sonar-web/src/main/js/components/measure/utils.js b/server/sonar-web/src/main/js/components/measure/utils.js index bf98a58ee7b..74cc616d680 100644 --- a/server/sonar-web/src/main/js/components/measure/utils.js +++ b/server/sonar-web/src/main/js/components/measure/utils.js @@ -24,10 +24,18 @@ import { getRatingTooltip as nextGetRatingTooltip, isDiffMetric } from '../../helpers/measures'; +import type { Measure, MeasureEnhanced } from './types'; import type { Metric } from '../../store/metrics/actions'; const KNOWN_RATINGS = ['sqale_rating', 'reliability_rating', 'security_rating']; +export const enhanceMeasure = (measure: Measure, metric: Metric): MeasureEnhanced => ({ + value: measure.value, + periods: measure.periods, + metric, + leak: getLeakValue(measure) +}); + export function formatLeak(value: ?string, metric: Metric, options: Object) { if (isDiffMetric(metric.key)) { return formatMeasure(value, metric.type, options); @@ -36,6 +44,14 @@ export function formatLeak(value: ?string, metric: Metric, options: Object) { } } +export function getLeakValue(measure: ?Measure): ?string { + if (!measure || !measure.periods) { + return null; + } + const period = measure.periods.find(period => period.index === 1); + return period ? period.value : null; +} + export function getRatingTooltip(metricKey: string, value: ?string) { const finalMetricKey = isDiffMetric(metricKey) ? metricKey.substr(4) : metricKey; if (KNOWN_RATINGS.includes(finalMetricKey)) { diff --git a/server/sonar-web/src/main/js/components/shared/complexity-distribution.js b/server/sonar-web/src/main/js/components/shared/ComplexityDistribution.js index 7853ee91800..0dd2ccc4514 100644 --- a/server/sonar-web/src/main/js/components/shared/complexity-distribution.js +++ b/server/sonar-web/src/main/js/components/shared/ComplexityDistribution.js @@ -25,7 +25,7 @@ import { translateWithParameters } from '../../helpers/l10n'; const HEIGHT = 80; -export class ComplexityDistribution extends React.PureComponent { +export default class ComplexityDistribution extends React.PureComponent { static propTypes = { distribution: PropTypes.string.isRequired, of: PropTypes.string.isRequired @@ -61,11 +61,8 @@ export class ComplexityDistribution extends React.PureComponent { }; render() { - // TODO remove inline styling return ( - <div - className="overview-bar-chart" - style={{ height: HEIGHT, paddingTop: 10, paddingBottom: 15 }}> + <div className="overview-bar-chart" style={{ height: HEIGHT }}> {this.renderBarChart()} </div> ); diff --git a/server/sonar-web/src/main/js/store/metrics/actions.js b/server/sonar-web/src/main/js/store/metrics/actions.js index d76e54233fb..c144c2e1527 100644 --- a/server/sonar-web/src/main/js/store/metrics/actions.js +++ b/server/sonar-web/src/main/js/store/metrics/actions.js @@ -32,11 +32,9 @@ export type Metric = { type: string }; -export type Metrics = Array<Metric>; - export const RECEIVE_METRICS = 'RECEIVE_METRICS'; -export const receiveMetrics = (metrics: Metrics) => ({ +export const receiveMetrics = (metrics: Array<Metric>) => ({ type: RECEIVE_METRICS, metrics }); diff --git a/server/sonar-web/src/main/js/store/metrics/reducer.js b/server/sonar-web/src/main/js/store/metrics/reducer.js index 0c0950c6864..edc8750186c 100644 --- a/server/sonar-web/src/main/js/store/metrics/reducer.js +++ b/server/sonar-web/src/main/js/store/metrics/reducer.js @@ -44,6 +44,6 @@ const keys = (state: StateKeys = [], action = {}) => { export default combineReducers({ byKey, keys }); -export const getMetrics = (state: State) => state.keys; - +export const getMetrics = (state: State) => state.byKey; export const getMetricByKey = (state: State, key: string) => state.byKey[key]; +export const getMetricsKey = (state: State) => state.keys; diff --git a/server/sonar-web/src/main/js/store/rootReducer.js b/server/sonar-web/src/main/js/store/rootReducer.js index e38f544dabf..7da4acd3dfc 100644 --- a/server/sonar-web/src/main/js/store/rootReducer.js +++ b/server/sonar-web/src/main/js/store/rootReducer.js @@ -93,6 +93,8 @@ export const getMetrics = state => fromMetrics.getMetrics(state.metrics); export const getMetricByKey = (state, key) => fromMetrics.getMetricByKey(state.metrics, key); +export const getMetricsKey = state => fromMetrics.getMetricsKey(state.metrics); + export const getGlobalNotifications = state => fromNotifications.getGlobal(state.notifications); export const getProjectsWithNotifications = state => |