From: stanislavh Date: Thu, 1 Jun 2023 11:31:17 +0000 (+0200) Subject: SONAR-19391 Adopt bubble chart to new design X-Git-Tag: 10.1.0.73491~148 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=76b6ef07b14ca6769d65cba7ee12c178427a60a9;p=sonarqube.git SONAR-19391 Adopt bubble chart to new design --- diff --git a/server/sonar-web/design-system/src/components/Checkbox.tsx b/server/sonar-web/design-system/src/components/Checkbox.tsx index 648fd6af48c..328205b2deb 100644 --- a/server/sonar-web/design-system/src/components/Checkbox.tsx +++ b/server/sonar-web/design-system/src/components/Checkbox.tsx @@ -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({ {right && children} void; +} + +export function ColorsLegend(props: ColorLegendProps) { + const { className, colors } = props; + const theme = useTheme(); + + return ( + + {colors.map((color, idx) => ( +
  • + +
    + { + props.onColorClick(color); + }} + > + + {color.label} + + +
    +
    +
  • + ))} +
    + ); +} + +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`} +`; diff --git a/server/sonar-web/design-system/src/components/DeferredSpinner.tsx b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx index 711214f2a2e..09c432d6873 100644 --- a/server/sonar-web/design-system/src/components/DeferredSpinner.tsx +++ b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx @@ -41,7 +41,7 @@ const DEFAULT_TIMEOUT = 100; export class DeferredSpinner extends React.PureComponent { 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 index 00000000000..cb70e5fe760 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/ColorsLegend-test.tsx @@ -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> = {}) { + return render(); +} diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 0d41427aa9c..27126e7a328 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -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'; diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index 9468d011da4..ed2ace43cdf 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -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 diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx index 4cbf1d5d3f6..7a90f5734fd 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx @@ -218,7 +218,7 @@ class ComponentMeasuresApp extends React.PureComponent { if (displayOverview) { return ( - + +
    {left}
    {right}
    - +
    ); } + +const StyledHeader = styled.div` + border-bottom: ${themeBorder('default', 'pageBlockBorder')}; +`; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx index 2bac35e3559..855983957b9 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx @@ -17,11 +17,11 @@ * 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 { ); }; - renderContent() { + renderContent(isFile: boolean) { const { branchLike, component, domain, metrics } = this.props; const { paging } = this.state; - if (isFile(component.qualifier)) { + if (isFile) { return (
    { } return ( - { render() { const { branchLike, className, component, leakPeriod, loading, rootComponent } = this.props; const displayLeak = hasFullMeasures(branchLike); + const isFileComponent = isFile(component.qualifier); + return (
    @@ -168,19 +170,26 @@ export default class MeasureOverview extends React.PureComponent { /> } right={ - + <> + + {leakPeriod && displayLeak && ( + + )} + } /> - {leakPeriod && displayLeak && ( - - )} - - - {!loading && this.renderContent()} +
    + + {!loading && this.renderContent(isFileComponent)} +
    ); } 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 index f7d36f2b5e8..00000000000 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx +++ /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; - paging?: Paging; - updateSelected: (component: ComponentMeasureIntern) => void; -} - -interface State { - ratingFilters: { [rating: number]: boolean }; -} - -export default class BubbleChart extends React.PureComponent { - 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 }, - 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 ( -
    - {inner.map((line, index) => ( - - {line} - {index < inner.length - 1 &&
    } -
    - ))} -
    - ); - } - - 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 ( - - 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 ( -
    - -
    - {title} - -
    - - {paging?.total && paging?.total > BUBBLES_FETCH_LIMIT && ( -
    - ({translate('component_measures.legend.only_first_500_files')}) -
    - )} -
    - - - {colorsMetric && ( - - {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]) - )} - - )} - {translateWithParameters( - 'component_measures.legend.size_x', - getLocalizedMetricName(sizeMetric) - )} - - {colorsMetric && ( - - )} - -
    - ); - } - - render() { - if (this.props.components.length <= 0) { - return ; - } - const { domain, componentKey, branchLike } = this.props; - const metrics = getBubbleMetrics(domain, this.props.metrics); - - return ( -
    - {this.renderChartHeader(domain, metrics.size, metrics.colors)} -
    -
    - - {translate('component_measures.overview.see_data_as_list')} - -
    - {this.renderBubbleChart(metrics)} -
    -
    - {getLocalizedMetricName(metrics.x)} -
    -
    - {getLocalizedMetricName(metrics.y)} -
    -
    - ); - } -} 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 index 00000000000..9eb1d4ac818 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChartView.tsx @@ -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; + paging?: Paging; + updateSelected: (component: ComponentMeasureIntern) => void; +} + +interface State { + ratingFilters: { [rating: number]: boolean }; +} + +export default class BubbleChartView extends React.PureComponent { + 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 }, + 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 ( +
    + {inner.map((line, index) => ( + + {line} + {index < inner.length - 1 &&
    } +
    + ))} +
    + ); + } + + 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 ( + + 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 ( +
    +
    +
    + {title} + + + +
    + + {paging?.total && paging?.total > BUBBLES_FETCH_LIMIT && ( +
    + ({translate('component_measures.legend.only_first_500_files')}) +
    + )} + {(isView(component?.qualifier) || isProject(component?.qualifier)) && ( +
    + + {translate('component_measures.overview.see_data_as_list')} + +
    + )} +
    + +
    +
    + {colorsMetric && ( + + + {translate('component_measures.legend.color')} + {' '} + {colorsMetric.length > 1 + ? translateWithParameters( + 'component_measures.legend.worse_of_x_y', + ...colorsMetric.map((metric) => getLocalizedMetricName(metric)) + ) + : getLocalizedMetricName(colorsMetric[0])} + + )} + + {translate('component_measures.legend.size')} + {' '} + {getLocalizedMetricName(sizeMetric)} +
    + {colorsMetric && ( + + )} +
    +
    + ); + } + + render() { + if (this.props.components.length <= 0) { + return ; + } + const { domain } = this.props; + const metrics = getBubbleMetrics(domain, this.props.metrics); + + return ( + + {this.renderChartHeader(domain, metrics.size, metrics.colors)} + {this.renderBubbleChart(metrics)} +
    {getLocalizedMetricName(metrics.x)}
    + + {getLocalizedMetricName(metrics.y)} + +
    + ); + } +} + +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 index 00000000000..63b303bc96b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/ColorRatingsLegend.tsx @@ -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 ( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx index f308d9ae487..7828bbc1689 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx @@ -92,7 +92,7 @@ export default function DomainSubnavigation(props: Props) { {sortedItems.map((item) => typeof item === 'string' ? ( showFullMeasures && ( - + {translate('component_measures.subnavigation_category', item)} ) diff --git a/server/sonar-web/src/main/js/apps/component-measures/style.css b/server/sonar-web/src/main/js/apps/component-measures/style.css index 251b014ba44..e4d8107cab4 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/style.css +++ b/server/sonar-web/src/main/js/apps/component-measures/style.css @@ -17,19 +17,6 @@ * 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 index c3706bde87a..00000000000 --- a/server/sonar-web/src/main/js/components/charts/ColorRatingsLegend.tsx +++ /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 ( -
      - {RATINGS.map((rating) => ( -
    • - - props.onRatingClick(rating)} - > - - {formatMeasure(rating, 'RATING')} - - - -
    • - ))} -
    - ); -} 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 index 6073e4d2eee..00000000000 --- a/server/sonar-web/src/main/js/components/charts/__tests__/ColorRatingsLegend-test.tsx +++ /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 = {}) { - return renderComponent( - - ); -} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 2433b4b33fe..2af9659f827 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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