]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11556 Make bubblechart legend actionable
authorJeremy Davis <jeremy.davis@sonarsource.com>
Thu, 3 Dec 2020 10:56:59 +0000 (11:56 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 4 Dec 2020 20:06:50 +0000 (20:06 +0000)
15 files changed:
server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx
server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/Risk.tsx
server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.tsx
server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/Risk-test.tsx
server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/SimpleBubbleChart-test.tsx
server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/Risk-test.tsx.snap
server/sonar-web/src/main/js/apps/projects/visualizations/__tests__/__snapshots__/SimpleBubbleChart-test.tsx.snap
server/sonar-web/src/main/js/components/charts/ColorBoxLegend.css
server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx
server/sonar-web/src/main/js/components/charts/__tests__/ColorRatingsLegend-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/ColorRatingsLegend-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/mocks/projects.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 56caaeda49f9ce3640a95b47423ce7416168f8b9..0009e8c28b1483e9cf9b894d254b1fbc1dc544b9 100644 (file)
@@ -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 (file)
index 0000000..8572136
--- /dev/null
@@ -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 (file)
index 0000000..19ba423
--- /dev/null
@@ -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>
+`;
index 161202d0c6bfb3737f862f298ac13f2da69076d9..e40860b22797910720f4edbcb18cc8276c4ca730 100644 (file)
@@ -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>
index 55f0ec925ddae6a4df80a56f3f133a5ea288a2e4..cf7fe2f6c14f9021dd417168117c15b927d5ea39 100644 (file)
@@ -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>
index b212b3be7850e2f865630858c04a4e9c3f553778..76f088fc9c6822523836ecc584a1db0802aaa942 100644 (file)
  */
 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}
+    />
+  );
+}
index 00a64d12e9e95b574e5bb9ed704396bfd5a820b5..831439af07ed3192c7814cd9abdde5ec34f0528a 100644 (file)
  */
 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}
+    />
+  );
+}
index 381818cef6bfb52a365c6c92f27a198e526c520b..e62a6b6a11e21d767f7f79aad05e25ad11e513c4 100644 (file)
@@ -13,7 +13,7 @@ exports[`renders 1`] = `
     items={
       Array [
         Object {
-          "color": undefined,
+          "color": "#eabe06",
           "key": "foo",
           "link": Object {
             "pathname": "/dashboard",
@@ -33,6 +33,56 @@ exports[`renders 1`] = `
                 Foo
               </strong>
             </div>
+            <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
               : 
@@ -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>
index 7d0403e03003113b652358d06aa1b79de6032fbc..bc2daa21d575a6723e80bc4dd00974dde5520c9d 100644 (file)
@@ -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>
index 2d09497e7ccb0426efc27687fec15d697992bc85..f36f3994725fa7f1776a49abf84c4e085f9cb2eb 100644 (file)
 .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;
+}
index aefb939580d9e54c165a0bed118ffde69c380684..9ae0e5271280ee07e682479aa9c7253be3b96f85 100644 (file)
  */
 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 (file)
index 0000000..5f2a3b8
--- /dev/null
@@ -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 (file)
index 0000000..52a280f
--- /dev/null
@@ -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 (file)
index 0000000..5c4c13f
--- /dev/null
@@ -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
+  };
+}
index fe486a541d3d0977ea631ab5a53c92a1dde229a3..738b934e13dd6f91f0437a94cd96ca4284d048c8 100644 (file)
@@ -3032,6 +3032,7 @@ component_measures.view_as=View as
 component_measures.legend.color_x=Color: {0}
 component_measures.legend.size_x=Size: {0}
 component_measures.legend.worse_of_x_y=Worse of {0} and {1}
+component_measures.legend.help=Click to toggle visibility.
 component_measures.no_history=There isn't enough data to generate an activity graph.
 component_measures.not_found=The requested measure was not found.
 component_measures.empty=No measures.