]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19391 Adopt bubble chart to new design
authorstanislavh <stanislav.honcharov@sonarsource.com>
Thu, 1 Jun 2023 11:31:17 +0000 (13:31 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 5 Jun 2023 20:02:47 +0000 (20:02 +0000)
17 files changed:
server/sonar-web/design-system/src/components/Checkbox.tsx
server/sonar-web/design-system/src/components/ColorsLegend.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DeferredSpinner.tsx
server/sonar-web/design-system/src/components/__tests__/ColorsLegend-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContentHeader.tsx
server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx
server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx [deleted file]
server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChartView.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/drilldown/ColorRatingsLegend.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx
server/sonar-web/src/main/js/apps/component-measures/style.css
server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx [deleted file]
server/sonar-web/src/main/js/components/charts/__tests__/ColorRatingsLegend-test.tsx [deleted file]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 648fd6af48c215acbaac6a3933e101e0d415b46d..328205b2deb9e7d80383a5e756c31f5fe4aa492e 100644 (file)
@@ -27,6 +27,7 @@ import { CheckIcon } from './icons/CheckIcon';
 import { CustomIcon } from './icons/Icon';
 
 interface Props {
+  ariaLabel?: string;
   checked: boolean;
   children?: React.ReactNode;
   className?: string;
@@ -42,6 +43,7 @@ interface Props {
 }
 
 export function Checkbox({
+  ariaLabel,
   checked,
   disabled,
   children,
@@ -65,7 +67,7 @@ export function Checkbox({
     <CheckboxContainer className={className} disabled={disabled}>
       {right && children}
       <AccessibleCheckbox
-        aria-label={title}
+        aria-label={ariaLabel ?? title}
         checked={checked}
         disabled={disabled ?? loading}
         id={id}
diff --git a/server/sonar-web/design-system/src/components/ColorsLegend.tsx b/server/sonar-web/design-system/src/components/ColorsLegend.tsx
new file mode 100644 (file)
index 0000000..74edf80
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { BubbleColorVal } from '../types/charts';
+import { Checkbox } from './Checkbox';
+import Tooltip from './Tooltip';
+
+import { themeBorder, themeColor, themeContrast } from '../helpers';
+
+export interface ColorFilterOption {
+  ariaLabel?: string;
+  backgroundColor?: string;
+  borderColor?: string;
+  label: React.ReactNode;
+  overlay?: React.ReactNode;
+  selected: boolean;
+  value: string | number;
+}
+
+interface ColorLegendProps {
+  className?: string;
+  colors: ColorFilterOption[];
+  onColorClick: (color: ColorFilterOption) => void;
+}
+
+export function ColorsLegend(props: ColorLegendProps) {
+  const { className, colors } = props;
+  const theme = useTheme();
+
+  return (
+    <ColorsLegendWrapper className={className}>
+      {colors.map((color, idx) => (
+        <li className="sw-ml-4" key={color.value}>
+          <Tooltip overlay={color.overlay}>
+            <div>
+              <Checkbox
+                ariaLabel={color.ariaLabel}
+                checked={color.selected}
+                onCheck={() => {
+                  props.onColorClick(color);
+                }}
+              >
+                <ColorRating
+                  style={
+                    color.selected
+                      ? {
+                          backgroundColor:
+                            color.borderColor ??
+                            themeColor(`bubble.${(idx + 1) as BubbleColorVal}`)({ theme }),
+                          borderColor:
+                            color.backgroundColor ??
+                            themeContrast(`bubble.${(idx + 1) as BubbleColorVal}`)({ theme }),
+                        }
+                      : {}
+                  }
+                >
+                  {color.label}
+                </ColorRating>
+              </Checkbox>
+            </div>
+          </Tooltip>
+        </li>
+      ))}
+    </ColorsLegendWrapper>
+  );
+}
+
+const ColorsLegendWrapper = styled.ul`
+  ${tw`sw-flex`}
+`;
+
+const ColorRating = styled.div`
+  width: 20px;
+  height: 20px;
+  line-height: 20px;
+  border-radius: 50%;
+  border: ${themeBorder()};
+  ${tw`sw-flex sw-justify-center`}
+  ${tw`sw-ml-1`}
+`;
index 711214f2a2e0ff189494bb2364fabddb41fe491d..09c432d68734687c9bb48970a574f9074838e326 100644 (file)
@@ -41,7 +41,7 @@ const DEFAULT_TIMEOUT = 100;
 
 export class DeferredSpinner extends React.PureComponent<Props, State> {
   timer?: number;
-
+  static displayName = 'DeferredSpinner';
   state: State = { showSpinner: false };
 
   componentDidMount() {
diff --git a/server/sonar-web/design-system/src/components/__tests__/ColorsLegend-test.tsx b/server/sonar-web/design-system/src/components/__tests__/ColorsLegend-test.tsx
new file mode 100644 (file)
index 0000000..cb70e5f
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react';
+
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { ColorsLegend } from '../ColorsLegend';
+
+const colors = [
+  {
+    selected: true,
+    overlay: 'Overlay A',
+    label: 'A',
+    value: '1',
+  },
+  {
+    selected: true,
+    overlay: 'Overlay B',
+    label: 'B',
+    value: '2',
+  },
+];
+
+it('should render correctly', () => {
+  renderColorLegend();
+  expect(screen.getByRole('checkbox', { name: 'A' })).toBeInTheDocument();
+  expect(screen.getByRole('checkbox', { name: 'B' })).toBeInTheDocument();
+});
+
+it('should react when a rating is clicked', () => {
+  const onColorClick = jest.fn();
+  renderColorLegend({ onColorClick });
+
+  screen.getByRole('checkbox', { name: 'A' }).click();
+  expect(onColorClick).toHaveBeenCalledWith(colors[0]);
+});
+
+function renderColorLegend(props: Partial<FCProps<typeof ColorsLegend>> = {}) {
+  return render(<ColorsLegend colors={colors} onColorClick={jest.fn()} {...props} />);
+}
index 0d41427aa9c3a2188b75898227c51785e7e79e15..27126e7a328f66f283b6dd7f41a9c4cbc9a6677b 100644 (file)
@@ -27,6 +27,7 @@ export * from './BubbleChart';
 export * from './Card';
 export * from './Checkbox';
 export * from './CodeSnippet';
+export * from './ColorsLegend';
 export * from './CoverageIndicator';
 export * from './DatePicker';
 export * from './DateRangePicker';
index 9468d011da431e7521212b52bcb90c03788c44c6..ed2ace43cdfb8c04c3ad88aa60ced871e3b868f6 100644 (file)
@@ -38,7 +38,11 @@ import GlobalNav from './nav/global/GlobalNav';
 import PromotionNotification from './promotion-notification/PromotionNotification';
 import UpdateNotification from './update-notification/UpdateNotification';
 
-const TEMP_PAGELIST_WITH_NEW_BACKGROUND = ['/dashboard', '/security_hotspots'];
+const TEMP_PAGELIST_WITH_NEW_BACKGROUND = [
+  '/dashboard',
+  '/security_hotspots',
+  '/component_measures',
+];
 
 export default function GlobalContainer() {
   // it is important to pass `location` down to `GlobalNav` to trigger render on url change
index 4cbf1d5d3f66438083fd117e1aef610e05e7a234..7a90f5734fda96cc0ac2ece0ba92754712b82536 100644 (file)
@@ -218,7 +218,7 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> {
 
     if (displayOverview) {
       return (
-        <StyledMain className="sw-rounded-1 sw-p-6 sw-mb-4">
+        <StyledMain className="sw-rounded-1 sw-mb-4">
           <MeasureOverviewContainer
             branchLike={branchLike}
             domain={query.metric}
@@ -333,7 +333,6 @@ function AppWithComponentContext() {
 export default AppWithComponentContext;
 
 const StyledMain = withTheme(styled.main`
-  background-color: ${themeColor('filterbar')};
   background-color: ${themeColor('pageBlock')};
   border: ${themeBorder('default', 'pageBlockBorder')};
 `);
index 8f061c919190e929144f3c76b5fd77c296ab4227..21af06f91578166612059a2c13ec3c67f811ffb0 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import styled from '@emotion/styled';
+import { themeBorder } from 'design-system';
 import * as React from 'react';
 
 interface Props {
@@ -26,9 +28,13 @@ interface Props {
 
 export default function MeasureContentHeader({ left, right }: Props) {
   return (
-    <div>
+    <StyledHeader className="sw-py-3 sw-px-6 sw-flex sw-justify-between sw-items-center">
       <div>{left}</div>
       <div>{right}</div>
-    </div>
+    </StyledHeader>
   );
 }
+
+const StyledHeader = styled.div`
+  border-bottom: ${themeBorder('default', 'pageBlockBorder')};
+`;
index 2bac35e3559ec2c5db17da1bfe9e89a5cc0451b6..855983957b9e71f1b7411ee6198ea8bb09369507 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { DeferredSpinner } from 'design-system';
 import * as React from 'react';
 import { getComponentLeaves } from '../../../api/components';
 import SourceViewer from '../../../components/SourceViewer/SourceViewer';
 import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import PageActions from '../../../components/ui/PageActions';
 import { getBranchLikeQuery, isSameBranchLike } from '../../../helpers/branch-like';
 import { BranchLike } from '../../../types/branch-like';
@@ -36,7 +36,7 @@ import {
   Paging,
   Period,
 } from '../../../types/types';
-import BubbleChart from '../drilldown/BubbleChart';
+import BubbleChartView from '../drilldown/BubbleChartView';
 import { BUBBLES_FETCH_LIMIT, enhanceComponent, getBubbleMetrics, hasFullMeasures } from '../utils';
 import LeakPeriodLegend from './LeakPeriodLegend';
 import MeasureContentHeader from './MeasureContentHeader';
@@ -121,11 +121,11 @@ export default class MeasureOverview extends React.PureComponent<Props, State> {
     );
   };
 
-  renderContent() {
+  renderContent(isFile: boolean) {
     const { branchLike, component, domain, metrics } = this.props;
     const { paging } = this.state;
 
-    if (isFile(component.qualifier)) {
+    if (isFile) {
       return (
         <div className="measure-details-viewer">
           <SourceViewer
@@ -138,8 +138,8 @@ export default class MeasureOverview extends React.PureComponent<Props, State> {
     }
 
     return (
-      <BubbleChart
-        componentKey={component.key}
+      <BubbleChartView
+        component={component}
         branchLike={branchLike}
         components={this.state.components}
         domain={domain}
@@ -153,6 +153,8 @@ export default class MeasureOverview extends React.PureComponent<Props, State> {
   render() {
     const { branchLike, className, component, leakPeriod, loading, rootComponent } = this.props;
     const displayLeak = hasFullMeasures(branchLike);
+    const isFileComponent = isFile(component.qualifier);
+
     return (
       <div className={className}>
         <A11ySkipTarget anchor="measures_main" />
@@ -168,19 +170,26 @@ export default class MeasureOverview extends React.PureComponent<Props, State> {
             />
           }
           right={
-            <PageActions
-              componentQualifier={rootComponent.qualifier}
-              current={this.state.components.length}
-            />
+            <>
+              <PageActions
+                componentQualifier={rootComponent.qualifier}
+                current={this.state.components.length}
+              />
+              {leakPeriod && displayLeak && (
+                <LeakPeriodLegend
+                  className="pull-right"
+                  component={component}
+                  period={leakPeriod}
+                />
+              )}
+            </>
           }
         />
-        {leakPeriod && displayLeak && (
-          <LeakPeriodLegend className="pull-right" component={component} period={leakPeriod} />
-        )}
 
-        <DeferredSpinner loading={loading} />
-
-        {!loading && this.renderContent()}
+        <div className="sw-p-6">
+          <DeferredSpinner loading={loading} />
+          {!loading && this.renderContent(isFileComponent)}
+        </div>
       </div>
     );
   }
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
deleted file mode 100644 (file)
index f7d36f2..0000000
+++ /dev/null
@@ -1,280 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { BubbleColorVal, BubbleChart as OriginalBubbleChart } from 'design-system';
-import * as React from 'react';
-import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend';
-import Link from '../../../components/common/Link';
-import HelpTooltip from '../../../components/controls/HelpTooltip';
-import {
-  getLocalizedMetricDomain,
-  getLocalizedMetricName,
-  translate,
-  translateWithParameters,
-} from '../../../helpers/l10n';
-import { formatMeasure, isDiffMetric } from '../../../helpers/measures';
-import { isDefined } from '../../../helpers/types';
-import { getComponentDrilldownUrl } from '../../../helpers/urls';
-import { BranchLike } from '../../../types/branch-like';
-import { isProject } from '../../../types/component';
-import { MetricKey } from '../../../types/metrics';
-import {
-  ComponentMeasureEnhanced,
-  ComponentMeasureIntern,
-  Dict,
-  Metric,
-  Paging,
-} from '../../../types/types';
-import {
-  BUBBLES_FETCH_LIMIT,
-  getBubbleMetrics,
-  getBubbleYDomain,
-  isProjectOverview,
-} from '../utils';
-import EmptyResult from './EmptyResult';
-
-const HEIGHT = 500;
-
-interface Props {
-  componentKey: string;
-  components: ComponentMeasureEnhanced[];
-  branchLike?: BranchLike;
-  domain: string;
-  metrics: Dict<Metric>;
-  paging?: Paging;
-  updateSelected: (component: ComponentMeasureIntern) => void;
-}
-
-interface State {
-  ratingFilters: { [rating: number]: boolean };
-}
-
-export default class BubbleChart extends React.PureComponent<Props, State> {
-  state: State = {
-    ratingFilters: {},
-  };
-
-  getMeasureVal = (component: ComponentMeasureEnhanced, metric: Metric) => {
-    const measure = component.measures.find((measure) => measure.metric.key === metric.key);
-    if (!measure) {
-      return undefined;
-    }
-    return Number(isDiffMetric(metric.key) ? measure.leak : measure.value);
-  };
-
-  getTooltip(
-    component: ComponentMeasureEnhanced,
-    values: { x: number; y: number; size: number; colors?: Array<number | undefined> },
-    metrics: { x: Metric; y: Metric; size: Metric; colors?: Metric[] }
-  ) {
-    const inner = [
-      [component.name, isProject(component.qualifier) ? component.branch : undefined]
-        .filter((s) => !!s)
-        .join(' / '),
-      `${metrics.x.name}: ${formatMeasure(values.x, metrics.x.type)}`,
-      `${metrics.y.name}: ${formatMeasure(values.y, metrics.y.type)}`,
-      `${metrics.size.name}: ${formatMeasure(values.size, metrics.size.type)}`,
-    ].filter((s) => !!s);
-    const { colors: valuesColors } = values;
-    const { colors: metricColors } = metrics;
-    if (valuesColors && metricColors) {
-      metricColors.forEach((metric, idx) => {
-        const colorValue = valuesColors[idx];
-        if (colorValue || colorValue === 0) {
-          inner.push(`${metric.name}: ${formatMeasure(colorValue, metric.type)}`);
-        }
-      });
-    }
-    return (
-      <div className="text-left">
-        {inner.map((line, index) => (
-          <React.Fragment key={index}>
-            {line}
-            {index < inner.length - 1 && <br />}
-          </React.Fragment>
-        ))}
-      </div>
-    );
-  }
-
-  handleRatingFilterClick = (selection: number) => {
-    this.setState(({ ratingFilters }) => {
-      return { ratingFilters: { ...ratingFilters, [selection]: !ratingFilters[selection] } };
-    });
-  };
-
-  handleBubbleClick = (component: ComponentMeasureEnhanced) => this.props.updateSelected(component);
-
-  getDescription(domain: string) {
-    const description = `component_measures.overview.${domain}.description`;
-    const translatedDescription = translate(description);
-    if (description === translatedDescription) {
-      return null;
-    }
-    return translatedDescription;
-  }
-
-  renderBubbleChart(metrics: { x: Metric; y: Metric; size: Metric; colors?: Metric[] }) {
-    const { ratingFilters } = this.state;
-
-    const items = this.props.components
-      .map((component) => {
-        const x = this.getMeasureVal(component, metrics.x);
-        const y = this.getMeasureVal(component, metrics.y);
-        const size = this.getMeasureVal(component, metrics.size);
-        const colors = metrics.colors?.map((metric) => this.getMeasureVal(component, metric));
-        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: (colorRating as BubbleColorVal) ?? 0,
-          data: component,
-          tooltip: this.getTooltip(component, { x, y, size, colors }, metrics),
-        };
-      })
-      .filter(isDefined);
-
-    const formatXTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.x.type);
-    const formatYTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.y.type);
-
-    let xDomain: [number, number] | undefined;
-    if (items.reduce((acc, item) => acc + item.x, 0) === 0) {
-      // All items are on the 0 axis. This won't display the grid on the X axis,
-      // which can make the graph a little hard to read. Force the display of
-      // the X grid.
-      xDomain = [0, 100];
-    }
-
-    return (
-      <OriginalBubbleChart<ComponentMeasureEnhanced>
-        data-testid="bubble-chart"
-        formatXTick={formatXTick}
-        formatYTick={formatYTick}
-        height={HEIGHT}
-        items={items}
-        onBubbleClick={this.handleBubbleClick}
-        padding={[0, 4, 50, 60]}
-        yDomain={getBubbleYDomain(this.props.domain)}
-        xDomain={xDomain}
-      />
-    );
-  }
-
-  renderChartHeader(domain: string, sizeMetric: Metric, colorsMetric?: Metric[]) {
-    const { ratingFilters } = this.state;
-    const { paging } = this.props;
-
-    const title = isProjectOverview(domain)
-      ? translate('component_measures.overview', domain, 'title')
-      : translateWithParameters(
-          'component_measures.domain_x_overview',
-          getLocalizedMetricDomain(domain)
-        );
-    return (
-      <div className="measure-overview-bubble-chart-header">
-        <span className="measure-overview-bubble-chart-title">
-          <div className="display-flex-center">
-            {title}
-            <HelpTooltip className="spacer-left" overlay={this.getDescription(domain)} />
-          </div>
-
-          {paging?.total && paging?.total > BUBBLES_FETCH_LIMIT && (
-            <div className="note spacer-top">
-              ({translate('component_measures.legend.only_first_500_files')})
-            </div>
-          )}
-        </span>
-        <span className="measure-overview-bubble-chart-legend">
-          <span className="note">
-            {colorsMetric && (
-              <span className="spacer-right">
-                {translateWithParameters(
-                  'component_measures.legend.color_x',
-                  colorsMetric.length > 1
-                    ? translateWithParameters(
-                        'component_measures.legend.worse_of_x_y',
-                        ...colorsMetric.map((metric) => getLocalizedMetricName(metric))
-                      )
-                    : getLocalizedMetricName(colorsMetric[0])
-                )}
-              </span>
-            )}
-            {translateWithParameters(
-              'component_measures.legend.size_x',
-              getLocalizedMetricName(sizeMetric)
-            )}
-          </span>
-          {colorsMetric && (
-            <ColorRatingsLegend
-              className="spacer-top"
-              filters={ratingFilters}
-              onRatingClick={this.handleRatingFilterClick}
-            />
-          )}
-        </span>
-      </div>
-    );
-  }
-
-  render() {
-    if (this.props.components.length <= 0) {
-      return <EmptyResult />;
-    }
-    const { domain, componentKey, branchLike } = this.props;
-    const metrics = getBubbleMetrics(domain, this.props.metrics);
-
-    return (
-      <div className="measure-overview-bubble-chart">
-        {this.renderChartHeader(domain, metrics.size, metrics.colors)}
-        <div className="measure-overview-bubble-chart-content">
-          <div className="text-center small spacer-top spacer-bottom">
-            <Link
-              to={getComponentDrilldownUrl({
-                componentKey,
-                branchLike,
-                metric: isProjectOverview(domain) ? MetricKey.violations : metrics.size.key,
-                listView: true,
-              })}
-            >
-              {translate('component_measures.overview.see_data_as_list')}
-            </Link>
-          </div>
-          {this.renderBubbleChart(metrics)}
-        </div>
-        <div className="measure-overview-bubble-chart-axis x">
-          {getLocalizedMetricName(metrics.x)}
-        </div>
-        <div className="measure-overview-bubble-chart-axis y">
-          {getLocalizedMetricName(metrics.y)}
-        </div>
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChartView.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChartView.tsx
new file mode 100644 (file)
index 0000000..9eb1d4a
--- /dev/null
@@ -0,0 +1,300 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import {
+  BubbleColorVal,
+  HelperHintIcon,
+  Highlight,
+  Link,
+  BubbleChart as OriginalBubbleChart,
+  themeColor,
+} from 'design-system';
+import * as React from 'react';
+import HelpTooltip from '../../../components/controls/HelpTooltip';
+import {
+  getLocalizedMetricDomain,
+  getLocalizedMetricName,
+  translate,
+  translateWithParameters,
+} from '../../../helpers/l10n';
+import { formatMeasure, isDiffMetric } from '../../../helpers/measures';
+import { isDefined } from '../../../helpers/types';
+import { getComponentDrilldownUrl } from '../../../helpers/urls';
+import { BranchLike } from '../../../types/branch-like';
+import { isProject, isView } from '../../../types/component';
+import { MetricKey } from '../../../types/metrics';
+import {
+  ComponentMeasureEnhanced,
+  ComponentMeasure as ComponentMeasureI,
+  ComponentMeasureIntern,
+  Dict,
+  Metric,
+  Paging,
+} from '../../../types/types';
+import {
+  BUBBLES_FETCH_LIMIT,
+  getBubbleMetrics,
+  getBubbleYDomain,
+  isProjectOverview,
+} from '../utils';
+import ColorRatingsLegend from './ColorRatingsLegend';
+import EmptyResult from './EmptyResult';
+
+const HEIGHT = 500;
+
+interface Props {
+  component: ComponentMeasureI;
+  components: ComponentMeasureEnhanced[];
+  branchLike?: BranchLike;
+  domain: string;
+  metrics: Dict<Metric>;
+  paging?: Paging;
+  updateSelected: (component: ComponentMeasureIntern) => void;
+}
+
+interface State {
+  ratingFilters: { [rating: number]: boolean };
+}
+
+export default class BubbleChartView extends React.PureComponent<Props, State> {
+  state: State = {
+    ratingFilters: {},
+  };
+
+  getMeasureVal = (component: ComponentMeasureEnhanced, metric: Metric) => {
+    const measure = component.measures.find((measure) => measure.metric.key === metric.key);
+    if (!measure) {
+      return undefined;
+    }
+    return Number(isDiffMetric(metric.key) ? measure.leak : measure.value);
+  };
+
+  getTooltip(
+    component: ComponentMeasureEnhanced,
+    values: { x: number; y: number; size: number; colors?: Array<number | undefined> },
+    metrics: { x: Metric; y: Metric; size: Metric; colors?: Metric[] }
+  ) {
+    const inner = [
+      [component.name, isProject(component.qualifier) ? component.branch : undefined]
+        .filter((s) => !!s)
+        .join(' / '),
+      `${metrics.x.name}: ${formatMeasure(values.x, metrics.x.type)}`,
+      `${metrics.y.name}: ${formatMeasure(values.y, metrics.y.type)}`,
+      `${metrics.size.name}: ${formatMeasure(values.size, metrics.size.type)}`,
+    ].filter((s) => !!s);
+    const { colors: valuesColors } = values;
+    const { colors: metricColors } = metrics;
+    if (valuesColors && metricColors) {
+      metricColors.forEach((metric, idx) => {
+        const colorValue = valuesColors[idx];
+        if (colorValue || colorValue === 0) {
+          inner.push(`${metric.name}: ${formatMeasure(colorValue, metric.type)}`);
+        }
+      });
+    }
+    return (
+      <div className="sw-text-left">
+        {inner.map((line, index) => (
+          <React.Fragment key={index}>
+            {line}
+            {index < inner.length - 1 && <br />}
+          </React.Fragment>
+        ))}
+      </div>
+    );
+  }
+
+  handleRatingFilterClick = (selection: number) => {
+    this.setState(({ ratingFilters }) => {
+      return { ratingFilters: { ...ratingFilters, [selection]: !ratingFilters[selection] } };
+    });
+  };
+
+  handleBubbleClick = (component: ComponentMeasureEnhanced) => this.props.updateSelected(component);
+
+  getDescription(domain: string) {
+    const description = `component_measures.overview.${domain}.description`;
+    const translatedDescription = translate(description);
+    if (description === translatedDescription) {
+      return null;
+    }
+    return translatedDescription;
+  }
+
+  renderBubbleChart(metrics: { x: Metric; y: Metric; size: Metric; colors?: Metric[] }) {
+    const { ratingFilters } = this.state;
+
+    const items = this.props.components
+      .map((component) => {
+        const x = this.getMeasureVal(component, metrics.x);
+        const y = this.getMeasureVal(component, metrics.y);
+        const size = this.getMeasureVal(component, metrics.size);
+        const colors = metrics.colors?.map((metric) => this.getMeasureVal(component, metric));
+        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: (colorRating as BubbleColorVal) ?? 0,
+          data: component,
+          tooltip: this.getTooltip(component, { x, y, size, colors }, metrics),
+        };
+      })
+      .filter(isDefined);
+
+    const formatXTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.x.type);
+    const formatYTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.y.type);
+
+    let xDomain: [number, number] | undefined;
+    if (items.reduce((acc, item) => acc + item.x, 0) === 0) {
+      // All items are on the 0 axis. This won't display the grid on the X axis,
+      // which can make the graph a little hard to read. Force the display of
+      // the X grid.
+      xDomain = [0, 100];
+    }
+
+    return (
+      <OriginalBubbleChart<ComponentMeasureEnhanced>
+        data-testid="bubble-chart"
+        formatXTick={formatXTick}
+        formatYTick={formatYTick}
+        height={HEIGHT}
+        items={items}
+        onBubbleClick={this.handleBubbleClick}
+        padding={[0, 4, 50, 100]}
+        yDomain={getBubbleYDomain(this.props.domain)}
+        xDomain={xDomain}
+      />
+    );
+  }
+
+  renderChartHeader(domain: string, sizeMetric: Metric, colorsMetric?: Metric[]) {
+    const { ratingFilters } = this.state;
+    const { paging, component, branchLike, metrics: propsMetrics } = this.props;
+    const metrics = getBubbleMetrics(domain, propsMetrics);
+
+    const title = isProjectOverview(domain)
+      ? translate('component_measures.overview', domain, 'title')
+      : translateWithParameters(
+          'component_measures.domain_x_overview',
+          getLocalizedMetricDomain(domain)
+        );
+
+    return (
+      <div className="sw-flex sw-justify-between sw-gap-3">
+        <div>
+          <div className="sw-flex sw-items-center sw-whitespace-nowrap">
+            <Highlight className="it__measure-overview-bubble-chart-title">{title}</Highlight>
+            <HelpTooltip className="spacer-left" overlay={this.getDescription(domain)}>
+              <HelperHintIcon />
+            </HelpTooltip>
+          </div>
+
+          {paging?.total && paging?.total > BUBBLES_FETCH_LIMIT && (
+            <div className="sw-mt-2">
+              ({translate('component_measures.legend.only_first_500_files')})
+            </div>
+          )}
+          {(isView(component?.qualifier) || isProject(component?.qualifier)) && (
+            <div className="sw-mt-2">
+              <Link
+                to={getComponentDrilldownUrl({
+                  componentKey: component.key,
+                  branchLike,
+                  metric: isProjectOverview(domain) ? MetricKey.violations : metrics.size.key,
+                  listView: true,
+                })}
+              >
+                {translate('component_measures.overview.see_data_as_list')}
+              </Link>
+            </div>
+          )}
+        </div>
+
+        <div className="sw-flex sw-flex-col sw-items-end">
+          <div className="sw-text-right">
+            {colorsMetric && (
+              <span className="sw-mr-3">
+                <strong className="sw-body-sm-highlight">
+                  {translate('component_measures.legend.color')}
+                </strong>{' '}
+                {colorsMetric.length > 1
+                  ? translateWithParameters(
+                      'component_measures.legend.worse_of_x_y',
+                      ...colorsMetric.map((metric) => getLocalizedMetricName(metric))
+                    )
+                  : getLocalizedMetricName(colorsMetric[0])}
+              </span>
+            )}
+            <strong className="sw-body-sm-highlight">
+              {translate('component_measures.legend.size')}
+            </strong>{' '}
+            {getLocalizedMetricName(sizeMetric)}
+          </div>
+          {colorsMetric && (
+            <ColorRatingsLegend
+              className="spacer-top"
+              filters={ratingFilters}
+              onRatingClick={this.handleRatingFilterClick}
+            />
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    if (this.props.components.length <= 0) {
+      return <EmptyResult />;
+    }
+    const { domain } = this.props;
+    const metrics = getBubbleMetrics(domain, this.props.metrics);
+
+    return (
+      <BubbleChartWrapper className="sw-relative sw-body-sm">
+        {this.renderChartHeader(domain, metrics.size, metrics.colors)}
+        {this.renderBubbleChart(metrics)}
+        <div className="sw-text-center">{getLocalizedMetricName(metrics.x)}</div>
+        <YAxis className="sw-absolute sw-top-1/2 sw-left-3">
+          {getLocalizedMetricName(metrics.y)}
+        </YAxis>
+      </BubbleChartWrapper>
+    );
+  }
+}
+
+const BubbleChartWrapper = styled.div`
+  color: ${themeColor('pageContentLight')};
+`;
+
+const YAxis = styled.div`
+  transform: rotate(-90deg) translateX(-50%);
+  transform-origin: left;
+`;
diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/ColorRatingsLegend.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/ColorRatingsLegend.tsx
new file mode 100644 (file)
index 0000000..63b303b
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.
+ */
+
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { ColorFilterOption, ColorsLegend } from 'design-system';
+import * as React from 'react';
+import { translateWithParameters } from '../../../helpers/l10n';
+import { formatMeasure } from '../../../helpers/measures';
+import { MetricType } from '../../../types/metrics';
+
+export interface ColorRatingsLegendProps {
+  className?: string;
+  filters: { [rating: number]: boolean };
+  onRatingClick: (selection: number) => void;
+}
+
+const RATINGS = [1, 2, 3, 4, 5];
+
+export default function ColorRatingsLegend(props: ColorRatingsLegendProps) {
+  const { className, filters } = props;
+
+  const ratingsColors = RATINGS.map((rating) => {
+    const formattedMeasure = formatMeasure(rating, MetricType.Rating);
+    return {
+      overlay: translateWithParameters('component_measures.legend.help_x', formattedMeasure),
+      ariaLabel: translateWithParameters('component_measures.legend.help_x', formattedMeasure),
+      label: formattedMeasure,
+      value: rating,
+      selected: !filters[rating],
+    };
+  });
+
+  const handleColorClick = (color: ColorFilterOption) => {
+    props.onRatingClick(color.value as number);
+  };
+
+  return (
+    <ColorsLegend className={className} colors={ratingsColors} onColorClick={handleColorClick} />
+  );
+}
index f308d9ae48726604fbe2b2575c2b6efbb3f50a60..7828bbc16891052b741fe3f3969084cc8460a05e 100644 (file)
@@ -92,7 +92,7 @@ export default function DomainSubnavigation(props: Props) {
       {sortedItems.map((item) =>
         typeof item === 'string' ? (
           showFullMeasures && (
-            <SubnavigationSubheading>
+            <SubnavigationSubheading key={item}>
               {translate('component_measures.subnavigation_category', item)}
             </SubnavigationSubheading>
           )
index 251b014ba440531d9da78419d77b6855741dbcc2..e4d8107cab40c8adfae1aede40c70ae23c7a1234 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-.domain-measures-value {
-  margin-right: 4px;
-}
-
-.domain-measures-value span {
-  line-height: 16px;
-}
-
-.domain-measures-value .rating {
-  margin-left: -4px;
-  margin-right: -4px;
-}
-
 button.search-navigator-facet {
   text-align: start;
 }
@@ -139,60 +126,3 @@ button.search-navigator-facet {
 .measure-favorite svg {
   vertical-align: middle;
 }
-
-.measure-overview-bubble-chart {
-  position: relative;
-  border: 1px solid var(--barBorderColor);
-  background-color: #fff;
-}
-
-.measure-overview-bubble-chart-content {
-  padding: 0;
-  padding-left: 60px;
-}
-
-.measure-overview-bubble-chart-header {
-  display: flex;
-  align-items: center;
-  padding: 16px;
-  border-bottom: 1px solid var(--barBorderColor);
-}
-
-.measure-overview-bubble-chart-title {
-  position: absolute;
-}
-
-.measure-overview-bubble-chart-legend {
-  display: flex;
-  flex-direction: column;
-  text-align: center;
-  flex-grow: 1;
-}
-
-.measure-overview-bubble-chart-footer {
-  padding: 15px 60px;
-  border-top: 1px solid var(--barBorderColor);
-  text-align: center;
-  font-size: var(--smallFontSize);
-  line-height: 1.4;
-}
-
-.measure-overview-bubble-chart-axis {
-  color: var(--secondFontColor);
-  font-size: var(--smallFontSize);
-}
-
-.measure-overview-bubble-chart-axis.x {
-  position: relative;
-  top: -8px;
-  padding-bottom: 8px;
-  text-align: center;
-}
-
-.measure-overview-bubble-chart-axis.y {
-  position: absolute;
-  top: 50%;
-  left: 30px;
-  transform: rotate(-90deg) translateX(-50%);
-  transform-origin: left;
-}
diff --git a/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx b/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx
deleted file mode 100644 (file)
index c3706bd..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 classNames from 'classnames';
-import * as React from 'react';
-import Tooltip from '../../components/controls/Tooltip';
-import { RATING_COLORS } from '../../helpers/constants';
-import { translateWithParameters } from '../../helpers/l10n';
-import { formatMeasure } from '../../helpers/measures';
-import Checkbox from '../controls/Checkbox';
-import './ColorBoxLegend.css';
-
-export interface ColorRatingsLegendProps {
-  className?: string;
-  filters: { [rating: number]: boolean };
-  onRatingClick: (selection: number) => void;
-}
-
-const RATINGS = [1, 2, 3, 4, 5];
-
-export default function ColorRatingsLegend(props: ColorRatingsLegendProps) {
-  const { className, filters } = props;
-  return (
-    <ul className={classNames('color-box-legend', className)}>
-      {RATINGS.map((rating) => (
-        <li key={rating}>
-          <Tooltip
-            overlay={translateWithParameters(
-              'component_measures.legend.help_x',
-              formatMeasure(rating, 'RATING')
-            )}
-          >
-            <Checkbox
-              className="display-flex-center"
-              checked={!filters[rating]}
-              onCheck={() => props.onRatingClick(rating)}
-            >
-              <span
-                className="color-box-legend-rating little-spacer-left"
-                style={{
-                  borderColor: RATING_COLORS[rating - 1].stroke,
-                  backgroundColor: RATING_COLORS[rating - 1].fillTransparent,
-                }}
-              >
-                {formatMeasure(rating, 'RATING')}
-              </span>
-            </Checkbox>
-          </Tooltip>
-        </li>
-      ))}
-    </ul>
-  );
-}
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
deleted file mode 100644 (file)
index 6073e4d..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react';
-import * as React from 'react';
-import { renderComponent } from '../../../helpers/testReactTestingUtils';
-import ColorRatingsLegend, { ColorRatingsLegendProps } from '../ColorRatingsLegend';
-
-it('should render correctly', () => {
-  renderColorRatingsLegend();
-  expect(screen.getByRole('checkbox', { name: 'A' })).toBeInTheDocument();
-  expect(screen.getByRole('checkbox', { name: 'B' })).toBeInTheDocument();
-  expect(screen.getByRole('checkbox', { name: 'C' })).toBeInTheDocument();
-  expect(screen.getByRole('checkbox', { name: 'D' })).toBeInTheDocument();
-  expect(screen.getByRole('checkbox', { name: 'E' })).toBeInTheDocument();
-});
-
-it('should react when a rating is clicked', () => {
-  const onRatingClick = jest.fn();
-  renderColorRatingsLegend({ onRatingClick });
-
-  screen.getByRole('checkbox', { name: 'D' }).click();
-  expect(onRatingClick).toHaveBeenCalledWith(4);
-});
-
-function renderColorRatingsLegend(props: Partial<ColorRatingsLegendProps> = {}) {
-  return renderComponent(
-    <ColorRatingsLegend filters={{ 2: true }} onRatingClick={jest.fn()} {...props} />
-  );
-}
index 2433b4b33feea3d77cd74096a60ee1ee833cbc87..2af9659f827a5617d7fd1676ec750cce8ef4325f 100644 (file)
@@ -3644,8 +3644,8 @@ component_measures.tab.tree=Tree
 component_measures.tab.list=List
 component_measures.tab.treemap=Treemap
 component_measures.view_as=View as
-component_measures.legend.color_x=Color: {0}
-component_measures.legend.size_x=Size: {0}
+component_measures.legend.color=Color:
+component_measures.legend.size=Size:
 component_measures.legend.worse_of_x_y=Worse of {0} and {1}
 component_measures.legend.help_x=Click to toggle visibility for data with rating {0}.
 component_measures.legend.only_first_500_files=Only showing data for the first 500 files