diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2020-12-03 11:56:59 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2020-12-04 20:06:50 +0000 |
commit | 17439910a22be35c8f4a9d601ab8a4b524df287a (patch) | |
tree | 8c582c96c295832b7ea66a598065f1513efdb9f4 /server | |
parent | 9676f33bfcdeb599e26de963a938e33e753261fe (diff) | |
download | sonarqube-17439910a22be35c8f4a9d601ab8a4b524df287a.tar.gz sonarqube-17439910a22be35c8f4a9d601ab8a4b524df287a.zip |
SONAR-11556 Make bubblechart legend actionable
Diffstat (limited to 'server')
14 files changed, 872 insertions, 93 deletions
diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx index 56caaeda49f..0009e8c28b1 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx @@ -44,7 +44,15 @@ interface Props { updateSelected: (component: string) => void; } -export default class BubbleChart extends React.PureComponent<Props> { +interface State { + ratingFilters: { [rating: number]: boolean }; +} + +export default class BubbleChart extends React.PureComponent<Props, State> { + state: State = { + ratingFilters: {} + }; + getMeasureVal = (component: T.ComponentMeasureEnhanced, metric: T.Metric) => { const measure = component.measures.find(measure => measure.metric.key === metric.key); if (!measure) { @@ -86,6 +94,12 @@ export default class BubbleChart extends React.PureComponent<Props> { ); } + handleRatingFilterClick = (selection: number) => { + this.setState(({ ratingFilters }) => { + return { ratingFilters: { ...ratingFilters, [selection]: !ratingFilters[selection] } }; + }); + }; + handleBubbleClick = (component: T.ComponentMeasureEnhanced) => this.props.updateSelected(component.refKey || component.key); @@ -99,6 +113,8 @@ export default class BubbleChart extends React.PureComponent<Props> { } renderBubbleChart(metrics: { x: T.Metric; y: T.Metric; size: T.Metric; colors?: T.Metric[] }) { + const { ratingFilters } = this.state; + const items = this.props.components .map(component => { const x = this.getMeasureVal(component, metrics.x); @@ -109,14 +125,19 @@ export default class BubbleChart extends React.PureComponent<Props> { if ((!x && x !== 0) || (!y && y !== 0) || (!size && size !== 0)) { return undefined; } + + const colorRating = colors && Math.max(...colors.filter(isDefined)); + + // Filter out items that match ratingFilters + if (colorRating !== undefined && ratingFilters[colorRating]) { + return undefined; + } + return { x, y, size, - color: - colors !== undefined - ? RATING_COLORS[Math.max(...colors.filter(isDefined)) - 1] - : undefined, + color: colorRating !== undefined ? RATING_COLORS[colorRating - 1] : undefined, data: component, tooltip: this.getTooltip(component.name, { x, y, size, colors }, metrics) }; @@ -140,6 +161,8 @@ export default class BubbleChart extends React.PureComponent<Props> { } renderChartHeader(domain: string, sizeMetric: T.Metric, colorsMetric?: T.Metric[]) { + const { ratingFilters } = this.state; + const title = isProjectOverview(domain) ? translate('component_measures.overview', domain, 'title') : translateWithParameters( @@ -172,7 +195,13 @@ export default class BubbleChart extends React.PureComponent<Props> { getLocalizedMetricName(sizeMetric) )} </span> - {colorsMetric && <ColorRatingsLegend className="spacer-top" />} + {colorsMetric && ( + <ColorRatingsLegend + className="spacer-top" + filters={ratingFilters} + onRatingClick={this.handleRatingFilterClick} + /> + )} </span> </div> ); diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx new file mode 100644 index 00000000000..85721364654 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx @@ -0,0 +1,75 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 { shallow } from 'enzyme'; +import { keyBy } from 'lodash'; +import * as React from 'react'; +import { mockComponentMeasure, mockMeasure, mockMetric } from '../../../../helpers/testMocks'; +import { MetricKey } from '../../../../types/metrics'; +import { enhanceComponent } from '../../utils'; +import BubbleChart from '../BubbleChart'; + +const metrics = keyBy( + [ + mockMetric({ key: MetricKey.ncloc, type: 'NUMBER' }), + mockMetric({ key: MetricKey.security_remediation_effort, type: 'NUMBER' }), + mockMetric({ key: MetricKey.vulnerabilities, type: 'NUMBER' }), + mockMetric({ key: MetricKey.security_rating, type: 'RATING' }) + ], + m => m.key +); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should handle filtering', () => { + const wrapper = shallowRender(); + + wrapper.instance().handleRatingFilterClick(2); + + expect(wrapper.state().ratingFilters).toEqual({ 2: true }); + expect(wrapper).toMatchSnapshot(); +}); + +function shallowRender(overrides: Partial<BubbleChart['props']> = {}) { + return shallow<BubbleChart>( + <BubbleChart + component={mockComponentMeasure()} + components={[ + enhanceComponent( + mockComponentMeasure(true, { + measures: [ + mockMeasure({ value: '236', metric: MetricKey.ncloc }), + mockMeasure({ value: '10', metric: MetricKey.security_remediation_effort }), + mockMeasure({ value: '3', metric: MetricKey.vulnerabilities }), + mockMeasure({ value: '2', metric: MetricKey.security_rating }) + ] + }), + metrics[MetricKey.vulnerabilities], + metrics + ) + ]} + domain="Security" + metrics={metrics} + updateSelected={jest.fn()} + {...overrides} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap new file mode 100644 index 00000000000..19ba423d5ed --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap @@ -0,0 +1,275 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should handle filtering 1`] = ` +<div + className="measure-overview-bubble-chart" +> + <div + className="measure-overview-bubble-chart-header" + > + <span + className="measure-overview-bubble-chart-title" + > + <span + className="text-middle" + > + component_measures.domain_x_overview.Security + </span> + <HelpTooltip + className="spacer-left" + overlay={null} + /> + </span> + <span + className="measure-overview-bubble-chart-legend" + > + <span + className="note" + > + <span + className="spacer-right" + > + component_measures.legend.color_x.Security_rating + </span> + component_measures.legend.size_x.Vulnerabilities + </span> + <ColorRatingsLegend + className="spacer-top" + filters={ + Object { + "2": true, + } + } + onRatingClick={[Function]} + /> + </span> + </div> + <div + className="measure-overview-bubble-chart-content" + > + <BubbleChart + displayXGrid={true} + displayXTicks={true} + displayYGrid={true} + displayYTicks={true} + formatXTick={[Function]} + formatYTick={[Function]} + height={500} + items={Array []} + onBubbleClick={[Function]} + padding={ + Array [ + 25, + 60, + 50, + 60, + ] + } + sizeRange={ + Array [ + 5, + 45, + ] + } + /> + </div> + <div + className="measure-overview-bubble-chart-axis x" + > + Ncloc + </div> + <div + className="measure-overview-bubble-chart-axis y" + > + Security_remediation_effort + </div> +</div> +`; + +exports[`should render correctly 1`] = ` +<div + className="measure-overview-bubble-chart" +> + <div + className="measure-overview-bubble-chart-header" + > + <span + className="measure-overview-bubble-chart-title" + > + <span + className="text-middle" + > + component_measures.domain_x_overview.Security + </span> + <HelpTooltip + className="spacer-left" + overlay={null} + /> + </span> + <span + className="measure-overview-bubble-chart-legend" + > + <span + className="note" + > + <span + className="spacer-right" + > + component_measures.legend.color_x.Security_rating + </span> + component_measures.legend.size_x.Vulnerabilities + </span> + <ColorRatingsLegend + className="spacer-top" + filters={Object {}} + onRatingClick={[Function]} + /> + </span> + </div> + <div + className="measure-overview-bubble-chart-content" + > + <BubbleChart + displayXGrid={true} + displayXTicks={true} + displayYGrid={true} + displayYTicks={true} + formatXTick={[Function]} + formatYTick={[Function]} + height={500} + items={ + Array [ + Object { + "color": "#b0d513", + "data": Object { + "key": "foo:src/index.tsx", + "leak": "1.0", + "measures": Array [ + Object { + "bestValue": true, + "leak": "1.0", + "metric": Object { + "id": "ncloc", + "key": "ncloc", + "name": "Ncloc", + "type": "NUMBER", + }, + "period": Object { + "bestValue": true, + "index": 1, + "value": "1.0", + }, + "value": "236", + }, + Object { + "bestValue": true, + "leak": "1.0", + "metric": Object { + "id": "security_remediation_effort", + "key": "security_remediation_effort", + "name": "Security_remediation_effort", + "type": "NUMBER", + }, + "period": Object { + "bestValue": true, + "index": 1, + "value": "1.0", + }, + "value": "10", + }, + Object { + "bestValue": true, + "leak": "1.0", + "metric": Object { + "id": "vulnerabilities", + "key": "vulnerabilities", + "name": "Vulnerabilities", + "type": "NUMBER", + }, + "period": Object { + "bestValue": true, + "index": 1, + "value": "1.0", + }, + "value": "3", + }, + Object { + "bestValue": true, + "leak": "1.0", + "metric": Object { + "id": "security_rating", + "key": "security_rating", + "name": "Security_rating", + "type": "RATING", + }, + "period": Object { + "bestValue": true, + "index": 1, + "value": "1.0", + }, + "value": "2", + }, + ], + "name": "index.tsx", + "path": "src/index.tsx", + "qualifier": "FIL", + "value": "3", + }, + "size": 3, + "tooltip": <div + className="text-left" + > + <React.Fragment> + index.tsx + <br /> + </React.Fragment> + <React.Fragment> + Ncloc: 236 + <br /> + </React.Fragment> + <React.Fragment> + Security_remediation_effort: 10 + <br /> + </React.Fragment> + <React.Fragment> + Vulnerabilities: 3 + <br /> + </React.Fragment> + <React.Fragment> + Security_rating: B + </React.Fragment> + </div>, + "x": 236, + "y": 10, + }, + ] + } + onBubbleClick={[Function]} + padding={ + Array [ + 25, + 60, + 50, + 60, + ] + } + sizeRange={ + Array [ + 5, + 45, + ] + } + /> + </div> + <div + className="measure-overview-bubble-chart-axis x" + > + Ncloc + </div> + <div + className="measure-overview-bubble-chart-axis y" + > + Security_remediation_effort + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/Risk.tsx b/server/sonar-web/src/main/js/apps/projects/visualizations/Risk.tsx index 161202d0c6b..e40860b2279 100644 --- a/server/sonar-web/src/main/js/apps/projects/visualizations/Risk.tsx +++ b/server/sonar-web/src/main/js/apps/projects/visualizations/Risk.tsx @@ -23,6 +23,7 @@ import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip'; import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { formatMeasure } from 'sonar-ui-common/helpers/measures'; +import { isDefined } from 'sonar-ui-common/helpers/types'; import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend'; import { RATING_COLORS } from '../../../helpers/constants'; import { getProjectUrl } from '../../../helpers/urls'; @@ -45,7 +46,15 @@ interface Props { projects: Project[]; } -export default class Risk extends React.PureComponent<Props> { +interface State { + ratingFilters: { [rating: number]: boolean }; +} + +export default class Risk extends React.PureComponent<Props, State> { + state: State = { + ratingFilters: {} + }; + getMetricTooltip(metric: { key: string; type: string }, value?: number) { const name = translate('metric', metric.key, 'name'); const formattedValue = value != null ? formatMeasure(value, metric.type) : '–'; @@ -102,33 +111,51 @@ export default class Risk extends React.PureComponent<Props> { ); } - render() { - const items = this.props.projects.map(project => { - const x = project.measures[X_METRIC] != null ? Number(project.measures[X_METRIC]) : undefined; - const y = project.measures[Y_METRIC] != null ? Number(project.measures[Y_METRIC]) : undefined; - const size = - project.measures[SIZE_METRIC] != null ? Number(project.measures[SIZE_METRIC]) : undefined; - const color1 = - project.measures[COLOR_METRIC_1] != null - ? Number(project.measures[COLOR_METRIC_1]) - : undefined; - const color2 = - project.measures[COLOR_METRIC_2] != null - ? Number(project.measures[COLOR_METRIC_2]) - : undefined; - return { - x: x || 0, - y: y || 0, - size: size || 0, - color: - color1 != null && color2 != null - ? RATING_COLORS[Math.max(color1, color2) - 1] - : undefined, - key: project.key, - tooltip: this.getTooltip(project, x, y, size, color1, color2), - link: getProjectUrl(project.key) - }; + handleRatingFilterClick = (selection: number) => { + this.setState(({ ratingFilters }) => { + return { ratingFilters: { ...ratingFilters, [selection]: !ratingFilters[selection] } }; }); + }; + + render() { + const { ratingFilters } = this.state; + + const items = this.props.projects + .map(project => { + const x = + project.measures[X_METRIC] != null ? Number(project.measures[X_METRIC]) : undefined; + const y = + project.measures[Y_METRIC] != null ? Number(project.measures[Y_METRIC]) : undefined; + const size = + project.measures[SIZE_METRIC] != null ? Number(project.measures[SIZE_METRIC]) : undefined; + const color1 = + project.measures[COLOR_METRIC_1] != null + ? Number(project.measures[COLOR_METRIC_1]) + : undefined; + const color2 = + project.measures[COLOR_METRIC_2] != null + ? Number(project.measures[COLOR_METRIC_2]) + : undefined; + + const colorRating = + color1 !== undefined && color2 !== undefined ? Math.max(color1, color2) : undefined; + + // Filter out items that match ratingFilters + if (colorRating !== undefined && ratingFilters[colorRating]) { + return undefined; + } + + return { + x: x || 0, + y: y || 0, + size: size || 0, + color: colorRating !== undefined ? RATING_COLORS[colorRating - 1] : undefined, + key: project.key, + tooltip: this.getTooltip(project, x, y, size, color1, color2), + link: getProjectUrl(project.key) + }; + }) + .filter(isDefined); const formatXTick = (tick: number) => formatMeasure(tick, X_METRIC_TYPE); const formatYTick = (tick: number) => formatMeasure(tick, Y_METRIC_TYPE); @@ -166,7 +193,11 @@ export default class Risk extends React.PureComponent<Props> { 'component_measures.legend.size_x', translate('metric', SIZE_METRIC, 'name') )} - <ColorRatingsLegend className="big-spacer-top" /> + <ColorRatingsLegend + className="big-spacer-top" + filters={ratingFilters} + onRatingClick={this.handleRatingFilterClick} + /> </div> </div> </div> diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.tsx b/server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.tsx index 55f0ec925dd..cf7fe2f6c14 100644 --- a/server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.tsx +++ b/server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.tsx @@ -23,6 +23,7 @@ import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip'; import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { formatMeasure } from 'sonar-ui-common/helpers/measures'; +import { isDefined } from 'sonar-ui-common/helpers/types'; import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend'; import { RATING_COLORS } from '../../../helpers/constants'; import { getProjectUrl } from '../../../helpers/urls'; @@ -46,7 +47,15 @@ interface Props { yMetric: Metric; } -export default class SimpleBubbleChart extends React.PureComponent<Props> { +interface State { + ratingFilters: { [rating: number]: boolean }; +} + +export default class SimpleBubbleChart extends React.PureComponent<Props, State> { + state: State = { + ratingFilters: {} + }; + getMetricTooltip(metric: Metric, value?: number) { const name = translate('metric', metric.key, 'name'); const formattedValue = value != null ? formatMeasure(value, metric.type) : '–'; @@ -96,8 +105,15 @@ export default class SimpleBubbleChart extends React.PureComponent<Props> { ); } + handleRatingFilterClick = (selection: number) => { + this.setState(({ ratingFilters }) => { + return { ratingFilters: { ...ratingFilters, [selection]: !ratingFilters[selection] } }; + }); + }; + render() { const { xMetric, yMetric, sizeMetric, colorMetric } = this.props; + const { ratingFilters } = this.state; const items = this.props.projects .filter(project => colorMetric == null || project.measures[colorMetric] !== null) @@ -111,6 +127,12 @@ export default class SimpleBubbleChart extends React.PureComponent<Props> { ? Number(project.measures[sizeMetric.key]) : undefined; const color = colorMetric ? Number(project.measures[colorMetric]) : undefined; + + // Filter out items that match ratingFilters + if (color && ratingFilters[color]) { + return undefined; + } + return { x: x || 0, y: y || 0, @@ -120,7 +142,8 @@ export default class SimpleBubbleChart extends React.PureComponent<Props> { tooltip: this.getTooltip(project, x, y, size, color), link: getProjectUrl(project.key) }; - }); + }) + .filter(isDefined); const formatXTick = (tick: number) => formatMeasure(tick, xMetric.type); const formatYTick = (tick: number) => formatMeasure(tick, yMetric.type); @@ -159,7 +182,13 @@ export default class SimpleBubbleChart extends React.PureComponent<Props> { 'component_measures.legend.size_x', translate('metric', sizeMetric.key, 'name') )} - {colorMetric != null && <ColorRatingsLegend className="big-spacer-top" />} + {colorMetric != null && ( + <ColorRatingsLegend + className="big-spacer-top" + filters={ratingFilters} + onRatingClick={this.handleRatingFilterClick} + /> + )} </div> </div> </div> diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/Risk-test.tsx b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/Risk-test.tsx index b212b3be785..76f088fc9c6 100644 --- a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/Risk-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/Risk-test.tsx @@ -19,20 +19,45 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { ComponentQualifier } from '../../../../types/component'; -import { Project } from '../../types'; +import { mockProject } from '../../../../helpers/mocks/projects'; import Risk from '../Risk'; it('renders', () => { - const project1: Project = { - key: 'foo', - measures: { complexity: '17.2', coverage: '53.5', ncloc: '1734' }, - name: 'Foo', - qualifier: ComponentQualifier.Project, - tags: [], - visibility: 'public' - }; - expect( - shallow(<Risk displayOrganizations={false} helpText="foobar" projects={[project1]} />) - ).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); }); + +it('should handle filtering', () => { + const wrapper = shallowRender(); + + wrapper.instance().handleRatingFilterClick(2); + + expect(wrapper.state().ratingFilters).toEqual({ 2: true }); +}); + +function shallowRender(overrides: Partial<Risk['props']> = {}) { + const project1 = mockProject({ + key: 'foo', + measures: { + complexity: '17.2', + coverage: '53.5', + ncloc: '1734', + sqale_index: '1', + reliability_rating: '3', + security_rating: '2' + }, + name: 'Foo' + }); + const project2 = mockProject({ + key: 'bar', + name: 'Bar', + measures: {} + }); + return shallow<Risk>( + <Risk + displayOrganizations={false} + helpText="foobar" + projects={[project1, project2]} + {...overrides} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/SimpleBubbleChart-test.tsx b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/SimpleBubbleChart-test.tsx index 00a64d12e9e..831439af07e 100644 --- a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/SimpleBubbleChart-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/SimpleBubbleChart-test.tsx @@ -19,37 +19,42 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; +import { mockProject } from '../../../../helpers/mocks/projects'; import { ComponentQualifier } from '../../../../types/component'; -import { Project } from '../../types'; import SimpleBubbleChart from '../SimpleBubbleChart'; it('renders', () => { - const project1: Project = { - key: 'foo', - measures: { complexity: '17.2', coverage: '53.5', ncloc: '1734', security_rating: '2' }, - name: 'Foo', - qualifier: ComponentQualifier.Project, - tags: [], - visibility: 'public' - }; - const app = { - ...project1, + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should handle filtering', () => { + const wrapper = shallowRender(); + + wrapper.instance().handleRatingFilterClick(2); + + expect(wrapper.state().ratingFilters).toEqual({ 2: true }); +}); + +function shallowRender(overrides: Partial<SimpleBubbleChart['props']> = {}) { + const project1 = mockProject({ + measures: { complexity: '17.2', coverage: '53.5', ncloc: '1734', security_rating: '2' } + }); + const app = mockProject({ key: 'app', measures: { complexity: '23.1', coverage: '87.3', ncloc: '32478', security_rating: '1' }, name: 'App', qualifier: ComponentQualifier.Application - }; - expect( - shallow( - <SimpleBubbleChart - colorMetric="security_rating" - displayOrganizations={false} - helpText="foobar" - projects={[app, project1]} - sizeMetric={{ key: 'ncloc', type: 'INT' }} - xMetric={{ key: 'complexity', type: 'INT' }} - yMetric={{ key: 'coverage', type: 'PERCENT' }} - /> - ) - ).toMatchSnapshot(); -}); + }); + return shallow<SimpleBubbleChart>( + <SimpleBubbleChart + colorMetric="security_rating" + displayOrganizations={false} + helpText="foobar" + projects={[app, project1]} + sizeMetric={{ key: 'ncloc', type: 'INT' }} + xMetric={{ key: 'complexity', type: 'INT' }} + yMetric={{ key: 'coverage', type: 'PERCENT' }} + {...overrides} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/Risk-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/Risk-test.tsx.snap index 381818cef6b..e62a6b6a11e 100644 --- a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/Risk-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/Risk-test.tsx.snap @@ -13,7 +13,7 @@ exports[`renders 1`] = ` items={ Array [ Object { - "color": undefined, + "color": "#eabe06", "key": "foo", "link": Object { "pathname": "/dashboard", @@ -36,6 +36,56 @@ exports[`renders 1`] = ` <div> metric.reliability_rating.name : + C + </div> + <div> + metric.security_rating.name + : + B + </div> + <div> + metric.coverage.name + : + 53.5% + </div> + <div> + metric.sqale_index.name + : + work_duration.x_minutes.1 + </div> + <div> + metric.ncloc.name + : + 1.7short_number_suffix.k + </div> + </div>, + "x": 1, + "y": 53.5, + }, + Object { + "color": undefined, + "key": "bar", + "link": Object { + "pathname": "/dashboard", + "query": Object { + "branch": undefined, + "id": "bar", + }, + }, + "size": 0, + "tooltip": <div + className="text-left" + > + <div + className="little-spacer-bottom display-flex-center display-flex-space-between" + > + <strong> + Bar + </strong> + </div> + <div> + metric.reliability_rating.name + : – </div> <div> @@ -46,7 +96,7 @@ exports[`renders 1`] = ` <div> metric.coverage.name : - 53.5% + – </div> <div> metric.sqale_index.name @@ -56,11 +106,11 @@ exports[`renders 1`] = ` <div> metric.ncloc.name : - 1.7short_number_suffix.k + – </div> </div>, "x": 0, - "y": 53.5, + "y": 0, }, ] } @@ -120,6 +170,8 @@ exports[`renders 1`] = ` component_measures.legend.size_x.metric.ncloc.name <ColorRatingsLegend className="big-spacer-top" + filters={Object {}} + onRatingClick={[Function]} /> </div> </div> diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/SimpleBubbleChart-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/SimpleBubbleChart-test.tsx.snap index 7d0403e0300..bc2daa21d57 100644 --- a/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/SimpleBubbleChart-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/SimpleBubbleChart-test.tsx.snap @@ -162,6 +162,8 @@ exports[`renders 1`] = ` component_measures.legend.size_x.metric.ncloc.name <ColorRatingsLegend className="big-spacer-top" + filters={Object {}} + onRatingClick={[Function]} /> </div> </div> diff --git a/server/sonar-web/src/main/js/components/charts/ColorBoxLegend.css b/server/sonar-web/src/main/js/components/charts/ColorBoxLegend.css index 2d09497e7cc..f36f3994725 100644 --- a/server/sonar-web/src/main/js/components/charts/ColorBoxLegend.css +++ b/server/sonar-web/src/main/js/components/charts/ColorBoxLegend.css @@ -43,3 +43,13 @@ .color-box-legend.color-box-full .color-box-legend-rect-inner { opacity: 1; } + +.color-box-legend button { + color: var(--baseFontColor); + border-bottom: none; + display: block; +} + +.color-box-legend button.filtered { + opacity: 0.3; +} diff --git a/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx b/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx index aefb939580d..9ae0e527128 100644 --- a/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx +++ b/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx @@ -19,29 +19,44 @@ */ import * as classNames from 'classnames'; import * as React from 'react'; +import { ButtonLink } from 'sonar-ui-common/components/controls/buttons'; +import Tooltip from 'sonar-ui-common/components/controls/Tooltip'; +import { translate } from 'sonar-ui-common/helpers/l10n'; import { formatMeasure } from 'sonar-ui-common/helpers/measures'; import { RATING_COLORS } from '../../helpers/constants'; import './ColorBoxLegend.css'; -interface Props { +export interface ColorRatingsLegendProps { className?: string; + filters: { [rating: number]: boolean }; + onRatingClick: (selection: number) => void; } -export default function ColorRatingsLegend({ className }: Props) { +const RATINGS = [1, 2, 3, 4, 5]; + +export default function ColorRatingsLegend(props: ColorRatingsLegendProps) { + const { className, filters } = props; return ( <div className={classNames('color-box-legend', className)}> - {[1, 2, 3, 4, 5].map(rating => ( - <div key={rating}> - <span - className="color-box-legend-rect" - style={{ borderColor: RATING_COLORS[rating - 1] }}> + {RATINGS.map(rating => ( + <Tooltip key={rating} overlay={translate('component_measures.legend.help')}> + <ButtonLink + className={classNames('little-padded-bottom', { + filtered: filters[rating] + })} + onClick={() => props.onRatingClick(rating)} + type="button"> <span - className="color-box-legend-rect-inner" - style={{ backgroundColor: RATING_COLORS[rating - 1] }} - /> - </span> - {formatMeasure(rating, 'RATING')} - </div> + className="color-box-legend-rect" + style={{ borderColor: RATING_COLORS[rating - 1] }}> + <span + className="color-box-legend-rect-inner" + style={{ backgroundColor: RATING_COLORS[rating - 1] }} + /> + </span> + {formatMeasure(rating, 'RATING')} + </ButtonLink> + </Tooltip> ))} </div> ); diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/ColorRatingsLegend-test.tsx b/server/sonar-web/src/main/js/components/charts/__tests__/ColorRatingsLegend-test.tsx new file mode 100644 index 00000000000..5f2a3b83c47 --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/__tests__/ColorRatingsLegend-test.tsx @@ -0,0 +1,45 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { ButtonLink } from 'sonar-ui-common/components/controls/buttons'; +import ColorRatingsLegend, { ColorRatingsLegendProps } from '../ColorRatingsLegend'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should callback when a rating is clicked', () => { + const onRatingClick = jest.fn(); + const wrapper = shallowRender({ onRatingClick }); + + wrapper + .find(ButtonLink) + .at(3) + .simulate('click'); + + expect(onRatingClick).toBeCalledWith(4); +}); + +function shallowRender(overrides: Partial<ColorRatingsLegendProps> = {}) { + return shallow( + <ColorRatingsLegend filters={{ 2: true }} onRatingClick={jest.fn()} {...overrides} /> + ); +} diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/ColorRatingsLegend-test.tsx.snap b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/ColorRatingsLegend-test.tsx.snap new file mode 100644 index 00000000000..52a280f871b --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/ColorRatingsLegend-test.tsx.snap @@ -0,0 +1,153 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<div + className="color-box-legend" +> + <Tooltip + key="1" + overlay="component_measures.legend.help" + > + <ButtonLink + className="little-padded-bottom" + onClick={[Function]} + type="button" + > + <span + className="color-box-legend-rect" + style={ + Object { + "borderColor": "#00aa00", + } + } + > + <span + className="color-box-legend-rect-inner" + style={ + Object { + "backgroundColor": "#00aa00", + } + } + /> + </span> + A + </ButtonLink> + </Tooltip> + <Tooltip + key="2" + overlay="component_measures.legend.help" + > + <ButtonLink + className="little-padded-bottom filtered" + onClick={[Function]} + type="button" + > + <span + className="color-box-legend-rect" + style={ + Object { + "borderColor": "#b0d513", + } + } + > + <span + className="color-box-legend-rect-inner" + style={ + Object { + "backgroundColor": "#b0d513", + } + } + /> + </span> + B + </ButtonLink> + </Tooltip> + <Tooltip + key="3" + overlay="component_measures.legend.help" + > + <ButtonLink + className="little-padded-bottom" + onClick={[Function]} + type="button" + > + <span + className="color-box-legend-rect" + style={ + Object { + "borderColor": "#eabe06", + } + } + > + <span + className="color-box-legend-rect-inner" + style={ + Object { + "backgroundColor": "#eabe06", + } + } + /> + </span> + C + </ButtonLink> + </Tooltip> + <Tooltip + key="4" + overlay="component_measures.legend.help" + > + <ButtonLink + className="little-padded-bottom" + onClick={[Function]} + type="button" + > + <span + className="color-box-legend-rect" + style={ + Object { + "borderColor": "#ed7d20", + } + } + > + <span + className="color-box-legend-rect-inner" + style={ + Object { + "backgroundColor": "#ed7d20", + } + } + /> + </span> + D + </ButtonLink> + </Tooltip> + <Tooltip + key="5" + overlay="component_measures.legend.help" + > + <ButtonLink + className="little-padded-bottom" + onClick={[Function]} + type="button" + > + <span + className="color-box-legend-rect" + style={ + Object { + "borderColor": "#d4333f", + } + } + > + <span + className="color-box-legend-rect-inner" + style={ + Object { + "backgroundColor": "#d4333f", + } + } + /> + </span> + E + </ButtonLink> + </Tooltip> +</div> +`; diff --git a/server/sonar-web/src/main/js/helpers/mocks/projects.ts b/server/sonar-web/src/main/js/helpers/mocks/projects.ts new file mode 100644 index 00000000000..5c4c13f97a0 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/mocks/projects.ts @@ -0,0 +1,33 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 { Project } from '../../apps/projects/types'; +import { ComponentQualifier } from '../../types/component'; + +export function mockProject(overrides: Partial<Project> = {}): Project { + return { + key: 'foo', + name: 'Foo', + measures: {}, + qualifier: ComponentQualifier.Project, + tags: [], + visibility: 'public', + ...overrides + }; +} |