From: stanislavh Date: Fri, 9 Aug 2024 13:15:03 +0000 (+0200) Subject: SONAR-22719 Measures page reflects new ratings X-Git-Tag: 10.7.0.96327~195 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=cb110bccb888b7089a12ffc6e03ed41f9b70749d;p=sonarqube.git SONAR-22719 Measures page reflects new ratings --- diff --git a/server/sonar-web/design-system/src/components/BubbleChart.tsx b/server/sonar-web/design-system/src/components/BubbleChart.tsx index 108527dbb93..bdca3c8f6a7 100644 --- a/server/sonar-web/design-system/src/components/BubbleChart.tsx +++ b/server/sonar-web/design-system/src/components/BubbleChart.tsx @@ -17,7 +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. */ -import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import classNames from 'classnames'; import { max, min } from 'd3-array'; @@ -31,13 +30,13 @@ import tw from 'twin.macro'; import { themeColor, themeContrast } from '../helpers'; import { ButtonSecondary } from '../sonar-aligned/components/buttons'; import { Note } from '../sonar-aligned/components/typography'; -import { BubbleColorVal } from '../types/charts'; import { Tooltip } from './Tooltip'; const TICKS_COUNT = 5; interface BubbleItem { - color?: BubbleColorVal; + backgroundColor?: string; + borderColor?: string; data?: T; key?: string; size: number; @@ -312,7 +311,8 @@ export function BubbleChart(props: BubbleChartProps) { const bubbles = sortBy(items, (b) => -b.size).map((item, index) => { return ( (props: BubbleChartProps) { } interface BubbleProps { - color?: BubbleColorVal; + backgroundColor?: string; + borderColor?: string; data?: T; onClick?: (ref?: T) => void; r: number; @@ -399,8 +400,7 @@ interface BubbleProps { } function Bubble(props: BubbleProps) { - const theme = useTheme(); - const { color, data, onClick, r, scale, tooltip, x, y } = props; + const { backgroundColor, borderColor, data, onClick, r, scale, tooltip, x, y } = props; const handleClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); @@ -415,8 +415,8 @@ function Bubble(props: BubbleProps) { diff --git a/server/sonar-web/design-system/src/components/ColorsLegend.tsx b/server/sonar-web/design-system/src/components/ColorsLegend.tsx index 43f7c415241..5974a136379 100644 --- a/server/sonar-web/design-system/src/components/ColorsLegend.tsx +++ b/server/sonar-web/design-system/src/components/ColorsLegend.tsx @@ -21,7 +21,7 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import tw from 'twin.macro'; import { themeBorder, themeColor, themeContrast } from '../helpers'; -import { BubbleColorVal } from '../types/charts'; +import { BubbleColorVal } from '../types'; import { Tooltip } from './Tooltip'; import { Checkbox } from './input/Checkbox'; @@ -63,11 +63,15 @@ export function ColorsLegend(props: ColorLegendProps) { color.selected ? { backgroundColor: - color.borderColor ?? - themeColor(`bubble.${(idx + 1) as BubbleColorVal}`)({ theme }), - borderColor: color.backgroundColor ?? - themeContrast(`bubble.${(idx + 1) as BubbleColorVal}`)({ theme }), + themeColor(`bubble.legacy.${(idx + 1) as BubbleColorVal}`)({ + theme, + }), + borderColor: + color.borderColor ?? + themeContrast(`bubble.legacy.${(idx + 1) as BubbleColorVal}`)({ + theme, + }), } : {} } diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index cd17f9674f5..098ca5ce763 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -629,18 +629,31 @@ export const lightTheme = { // bubble charts bubbleChartLine: COLORS.grey[50], bubbleDefault: [...COLORS.blue[500], 0.3], + 'bubble.legacy.1': [...COLORS.green[500], 0.3], + 'bubble.legacy.2': [...COLORS.yellowGreen[500], 0.3], + 'bubble.legacy.3': [...COLORS.yellow[500], 0.3], + 'bubble.legacy.4': [...COLORS.orange[500], 0.3], + 'bubble.legacy.5': [...COLORS.red[500], 0.3], + 'bubble.1': [...COLORS.green[500], 0.3], 'bubble.2': [...COLORS.yellowGreen[500], 0.3], 'bubble.3': [...COLORS.yellow[500], 0.3], - 'bubble.4': [...COLORS.orange[500], 0.3], + 'bubble.4': [...COLORS.red[500], 0.3], 'bubble.5': [...COLORS.red[500], 0.3], // TreeMap Colors + 'treeMap.legacy.A': COLORS.green[500], + 'treeMap.legacy.B': COLORS.yellowGreen[500], + 'treeMap.legacy.C': COLORS.yellow[500], + 'treeMap.legacy.D': COLORS.orange[500], + 'treeMap.legacy.E': COLORS.red[500], + 'treeMap.A': COLORS.green[500], 'treeMap.B': COLORS.yellowGreen[500], 'treeMap.C': COLORS.yellow[500], - 'treeMap.D': COLORS.orange[500], + 'treeMap.D': COLORS.red[500], 'treeMap.E': COLORS.red[500], + 'treeMap.NA1': COLORS.blueGrey[300], 'treeMap.NA2': COLORS.blueGrey[200], treeMapCellTextColor: COLORS.blueGrey[900], @@ -907,10 +920,16 @@ export const lightTheme = { // bubble charts bubbleDefault: COLORS.blue[500], + 'bubble.legacy.1': COLORS.green[500], + 'bubble.legacy.2': COLORS.yellowGreen[500], + 'bubble.legacy.3': COLORS.yellow[500], + 'bubble.legacy.4': COLORS.orange[500], + 'bubble.legacy.5': COLORS.red[500], + 'bubble.1': COLORS.green[500], 'bubble.2': COLORS.yellowGreen[500], 'bubble.3': COLORS.yellow[500], - 'bubble.4': COLORS.orange[500], + 'bubble.4': COLORS.red[500], 'bubble.5': COLORS.red[500], // news bar diff --git a/server/sonar-web/src/main/js/api/components.ts b/server/sonar-web/src/main/js/api/components.ts index 654b004e92f..7c3d752adda 100644 --- a/server/sonar-web/src/main/js/api/components.ts +++ b/server/sonar-web/src/main/js/api/components.ts @@ -91,14 +91,6 @@ export function getComponentTree( return getJSON(url, data).catch(throwGlobalError); } -export function getComponentLeaves( - component: string, - metrics: string[] = [], - additional: RequestData = {}, -) { - return getComponentTree('leaves', component, metrics, additional); -} - export function getComponent( data: { component: string; metricKeys: string } & BranchParameters, ): Promise<{ component: ComponentMeasure }> { diff --git a/server/sonar-web/src/main/js/api/measures.ts b/server/sonar-web/src/main/js/api/measures.ts index b7e1a89cf84..2cc7089cdca 100644 --- a/server/sonar-web/src/main/js/api/measures.ts +++ b/server/sonar-web/src/main/js/api/measures.ts @@ -35,32 +35,6 @@ export function getMeasures( return getJSON(COMPONENT_URL, data).then((r) => r.component.measures, throwGlobalError); } -export function getMeasuresWithMetrics( - component: string, - metrics: string[], - branchParameters?: BranchParameters, -): Promise { - return getJSON(COMPONENT_URL, { - additionalFields: 'metrics', - component, - metricKeys: metrics.join(','), - ...branchParameters, - }).catch(throwGlobalError); -} - -export function getMeasuresWithPeriod( - component: string, - metrics: string[], - branchParameters?: BranchParameters, -): Promise { - return getJSON(COMPONENT_URL, { - additionalFields: 'period', - component, - metricKeys: metrics.join(','), - ...branchParameters, - }).catch(throwGlobalError); -} - export function getMeasuresWithPeriodAndMetrics( component: string, metrics: string[], diff --git a/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts index 0a322db27d6..1d1ed348c38 100644 --- a/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts @@ -43,7 +43,6 @@ import { getComponent, getComponentData, getComponentForSourceViewer, - getComponentLeaves, getComponentTree, getDuplications, getSources, @@ -103,7 +102,6 @@ export default class ComponentsServiceMock { jest.mocked(getDuplications).mockImplementation(this.handleGetDuplications); jest.mocked(getSources).mockImplementation(this.handleGetSources); jest.mocked(changeKey).mockImplementation(this.handleChangeKey); - jest.mocked(getComponentLeaves).mockImplementation(this.handleGetComponentLeaves); jest.mocked(getBreadcrumbs).mockImplementation(this.handleGetBreadcrumbs); jest.mocked(setProjectTags).mockImplementation(this.handleSetProjectTags); jest.mocked(setApplicationTags).mockImplementation(this.handleSetApplicationTags); @@ -383,19 +381,6 @@ export default class ComponentsServiceMock { return Promise.reject({ status: 404, message: 'Component not found' }); }; - handleGetComponentLeaves = ( - component: string, - metrics: string[] = [], - data: RequestData = {}, - ): Promise<{ - baseComponent: ComponentMeasure; - components: ComponentMeasure[]; - metrics: Metric[]; - paging: Paging; - }> => { - return this.handleGetComponentTree('leaves', component, metrics, data); - }; - handleGetBreadcrumbs = ({ component: key }: { component: string } & BranchParameters) => { const base = this.findComponentTree(key); if (base === undefined) { diff --git a/server/sonar-web/src/main/js/api/mocks/MeasuresServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/MeasuresServiceMock.ts index 6c9fb99a6c2..45a5dedc2b0 100644 --- a/server/sonar-web/src/main/js/api/mocks/MeasuresServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/MeasuresServiceMock.ts @@ -22,7 +22,7 @@ import { BranchParameters } from '~sonar-aligned/types/branch-like'; import { MetricKey } from '~sonar-aligned/types/metrics'; import { mockMetric, mockPeriod } from '../../helpers/testMocks'; import { Metric, Period } from '../../types/types'; -import { getMeasures, getMeasuresWithPeriod, getMeasuresWithPeriodAndMetrics } from '../measures'; +import { getMeasures, getMeasuresWithPeriodAndMetrics } from '../measures'; import { ComponentTree, mockFullComponentTree } from './data/components'; import { mockIssuesList } from './data/issues'; import { MeasureRecords, getMetricTypeFromKey, mockFullMeasureData } from './data/measures'; @@ -51,7 +51,6 @@ export class MeasuresServiceMock { }; jest.mocked(getMeasures).mockImplementation(this.handleGetMeasures); - jest.mocked(getMeasuresWithPeriod).mockImplementation(this.handleGetMeasuresWithPeriod); jest .mocked(getMeasuresWithPeriodAndMetrics) .mockImplementation(this.handleGetMeasuresWithPeriodAndMetrics); @@ -107,23 +106,6 @@ export class MeasuresServiceMock { return this.reply(measures); }; - handleGetMeasuresWithPeriod = ( - component: string, - metrics: string[], - _branchParameters?: BranchParameters, - ) => { - const entry = this.findComponentTree(component); - const measures = this.filterMeasures(entry.component.key, metrics); - - return this.reply({ - component: { - ...entry.component, - measures, - }, - period: this.#period, - }); - }; - handleGetMeasuresWithPeriodAndMetrics = (componentKey: string, metricKeys: string[]) => { const { component } = this.findComponentTree(componentKey); const measures = this.filterMeasures(component.key, metricKeys); diff --git a/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts index cd33aaa83e1..49d90bfbdf5 100644 --- a/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts @@ -137,6 +137,10 @@ export default class SettingsServiceMock { key: SettingsKey.QPAdminCanDisableInheritedRules, value: 'true', }, + { + key: 'sonar.old_world', + value: 'false', + }, ]; #settingValues: SettingValue[] = cloneDeep(this.#defaultValues); diff --git a/server/sonar-web/src/main/js/app/components/metrics/RatingComponent.tsx b/server/sonar-web/src/main/js/app/components/metrics/RatingComponent.tsx index 5f3990dd8a7..69132914ef8 100644 --- a/server/sonar-web/src/main/js/app/components/metrics/RatingComponent.tsx +++ b/server/sonar-web/src/main/js/app/components/metrics/RatingComponent.tsx @@ -26,8 +26,10 @@ import { getLeakValue } from '../../../components/measure/utils'; import { isDiffMetric } from '../../../helpers/measures'; import { useMeasureQuery } from '../../../queries/measures'; import { useIsLegacyCCTMode } from '../../../queries/settings'; +import { BranchLike } from '../../../types/branch-like'; interface Props { + branchLike?: BranchLike; className?: string; componentKey: string; getLabel?: (rating: RatingEnum) => string; @@ -43,8 +45,17 @@ type RatingMetricKeys = | MetricKey.security_review_rating | MetricKey.releasability_rating; +function isNewRatingMetric(metricKey: MetricKey) { + return metricKey.includes('_new'); +} + const useGetMetricKeyForRating = (ratingMetric: RatingMetricKeys): MetricKey | null => { const { data: isLegacy, isLoading } = useIsLegacyCCTMode(); + + if (isNewRatingMetric(ratingMetric)) { + return ratingMetric; + } + if (isLoading) { return null; } @@ -52,16 +63,18 @@ const useGetMetricKeyForRating = (ratingMetric: RatingMetricKeys): MetricKey | n }; export default function RatingComponent(props: Readonly) { - const { componentKey, ratingMetric, size, className, getLabel, getTooltip } = props; + const { componentKey, ratingMetric, size, className, getLabel, branchLike, getTooltip } = props; + const metricKey = useGetMetricKeyForRating(ratingMetric as RatingMetricKeys); const { data: isLegacy } = useIsLegacyCCTMode(); const { data: targetMeasure, isLoading: isLoadingTargetMeasure } = useMeasureQuery( - { componentKey, metricKey: metricKey ?? '' }, + { componentKey, metricKey: metricKey ?? '', branchLike }, { enabled: !!metricKey }, ); + const { data: oldMeasure, isLoading: isLoadingOldMeasure } = useMeasureQuery( - { componentKey, metricKey: ratingMetric }, - { enabled: !isLegacy && targetMeasure === null }, + { componentKey, metricKey: ratingMetric, branchLike }, + { enabled: !isLegacy && !isNewRatingMetric(ratingMetric) && targetMeasure === null }, ); const isLoading = isLoadingTargetMeasure || isLoadingOldMeasure; diff --git a/server/sonar-web/src/main/js/apps/code/components/Component.tsx b/server/sonar-web/src/main/js/apps/code/components/Component.tsx index 133f2331c77..3377e14205e 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Component.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/Component.tsx @@ -104,7 +104,12 @@ export default function Component(props: Props) { {metrics.map((metric) => ( - + ))} {showAnalysisDate && ( diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx index 2e76c81919d..ba8d3b50824 100644 --- a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx @@ -34,16 +34,18 @@ import { areCCTMeasuresComputed as areCCTMeasuresComputedFn, isDiffMetric, } from '../../../helpers/measures'; +import { BranchLike } from '../../../types/branch-like'; import { isApplication, isProject } from '../../../types/component'; import { Metric, ComponentMeasure as TypeComponentMeasure } from '../../../types/types'; interface Props { + branchLike?: BranchLike; component: TypeComponentMeasure; metric: Metric; } export default function ComponentMeasure(props: Props) { - const { component, metric } = props; + const { component, metric, branchLike } = props; const isProjectLike = isProject(component.qualifier) || isApplication(component.qualifier); const isReleasability = metric.key === MetricKey.releasability_rating; @@ -89,13 +91,18 @@ export default function ComponentMeasure(props: Props) { case MetricType.Rating: return ( - + ); default: return ( { componentsHandler.reset(); measuresHandler.reset(); issuesHandler.reset(); branchHandler.reset(); + settingsHandler.reset(); }); describe('rendering', () => { it('should correctly render the default overview and navigation', async () => { const { ui, user } = getPageObject(); renderMeasuresApp(); - await ui.appLoaded(); // Overview. - expect(ui.seeDataAsListLink.get()).toBeInTheDocument(); + expect(await ui.seeDataAsListLink.find()).toBeInTheDocument(); expect(ui.overviewDomainLink.get()).toHaveAttribute('aria-current', 'true'); expect(ui.bubbleChart.get()).toBeInTheDocument(); expect(within(ui.bubbleChart.get()).getAllByRole('link')).toHaveLength(8); @@ -91,11 +93,11 @@ describe('rendering', () => { 'component_measures.metric.new_maintainability_issues.name 5', 'Added Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio on New Code 1.0%', - 'Maintainability Rating on New Code metric.has_rating_X.E', + 'Maintainability Rating on New Code metric.has_rating_X.E metric.sqale_rating.tooltip.E.0.0%', 'component_measures.metric.maintainability_issues.name 2', 'Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio 1.0%', - 'Maintainability Rating metric.has_rating_X.E', + 'Maintainability Rating metric.has_rating_X.E metric.sqale_rating.tooltip.E.0.0%', 'Effort to Reach Maintainability Rating A work_duration.x_minutes.1', ].forEach((measure) => { expect(ui.measureLink(measure).get()).toBeInTheDocument(); @@ -116,11 +118,11 @@ describe('rendering', () => { 'component_measures.metric.new_code_smells.name 9', 'Added Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio on New Code 1.0%', - 'Maintainability Rating on New Code metric.has_rating_X.E', + 'Maintainability Rating on New Code metric.has_rating_X.E metric.sqale_rating.tooltip.E.0.0%', 'component_measures.metric.code_smells.name 9', 'Technical Debt work_duration.x_minutes.1', 'Technical Debt Ratio 1.0%', - 'Maintainability Rating metric.has_rating_X.E', + 'Maintainability Rating metric.has_rating_X.E metric.sqale_rating.tooltip.E.0.0%', 'Effort to Reach Maintainability Rating A work_duration.x_minutes.1', ].forEach((measure) => { expect(ui.measureLink(measure).get()).toBeInTheDocument(); @@ -131,27 +133,26 @@ describe('rendering', () => { it('should correctly render a list view', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=list'); - await ui.appLoaded(); - expect(ui.measuresTable.get()).toBeInTheDocument(); + expect(await ui.measuresTable.find()).toBeInTheDocument(); expect(ui.measuresRows.getAll()).toHaveLength(8); }); it('should correctly render a tree view', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=tree'); - await ui.appLoaded(); - expect(ui.measuresTable.get()).toBeInTheDocument(); + expect(await ui.measuresTable.find()).toBeInTheDocument(); expect(ui.measuresRows.getAll()).toHaveLength(7); }); it('should correctly render a rating treemap view', async () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=sqale_rating&view=treemap'); - await ui.appLoaded(); - expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7); + await waitFor(() => { + expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7); + }); expect(ui.treeMapCell(/folderA .+ Maintainability Rating: C/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/test1\.js .+ Maintainability Rating: B/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/index\.tsx .+ Maintainability Rating: A/).get()).toBeInTheDocument(); @@ -175,9 +176,10 @@ describe('rendering', () => { const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=coverage&view=treemap'); - await ui.appLoaded(); - expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7); + await waitFor(() => { + expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7); + }); expect(ui.treeMapCell(/folderA .+ Coverage: 74.2%/).get()).toBeInTheDocument(); expect(ui.treeMapCell(/test1\.js .+ Coverage: —/).get()).toBeInTheDocument(); @@ -246,11 +248,9 @@ describe('rendering', () => { }); it('should correctly render the language distribution', async () => { - const { ui } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=ncloc'); - await ui.appLoaded(); - expect(screen.getByText('10short_number_suffix.k')).toBeInTheDocument(); + expect(await screen.findByText('10short_number_suffix.k')).toBeInTheDocument(); expect(screen.getByText('java')).toBeInTheDocument(); expect(screen.getByText('5short_number_suffix.k')).toBeInTheDocument(); expect(screen.getByText('javascript')).toBeInTheDocument(); @@ -294,9 +294,8 @@ describe('rendering', () => { const { ui, user } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=sqale_rating&view=list'); - await ui.appLoaded(); - expect(ui.notShowingAllComponentsTxt.get()).toBeInTheDocument(); + expect(await ui.notShowingAllComponentsTxt.find()).toBeInTheDocument(); await user.click(ui.showAllBtn.get()); expect(ui.notShowingAllComponentsTxt.query()).not.toBeInTheDocument(); }); @@ -401,7 +400,13 @@ describe('navigation', () => { await ui.appLoaded(); await user.click(ui.maintainabilityDomainBtn.get()); - await user.click(ui.measureLink('Maintainability Rating metric.has_rating_X.E').get()); + await user.click( + ui + .measureLink( + 'Maintainability Rating metric.has_rating_X.E metric.sqale_rating.tooltip.E.0.0%', + ) + .get(), + ); // Click treemap option in view select await user.click(ui.viewSelect.get()); @@ -528,8 +533,7 @@ it('should allow to load more components', async () => { const { ui, user } = getPageObject(); renderMeasuresApp('component_measures?id=foo&metric=code_smells&view=list'); - await ui.appLoaded(); - await user.click(ui.showAllBtn.get()); + await user.click(await ui.showAllBtn.find()); expect(ui.showingOutOfTxt('500', '1,008').get()).toBeInTheDocument(); await ui.clickLoadMore(); 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 b5c6128f0e7..b3f01a65d3b 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 @@ -28,29 +28,29 @@ import { themeBorder, themeColor, } from 'design-system'; -import { keyBy } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import HelpTooltip from '~sonar-aligned/components/controls/HelpTooltip'; -import { withRouter } from '~sonar-aligned/components/hoc/withRouter'; +import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter'; import { getBranchLikeQuery, isPullRequest } from '~sonar-aligned/helpers/branch-like'; import { isPortfolioLike } from '~sonar-aligned/helpers/component'; import { ComponentQualifier } from '~sonar-aligned/types/component'; import { MetricKey } from '~sonar-aligned/types/metrics'; -import { Location, Router } from '~sonar-aligned/types/router'; -import { getMeasuresWithPeriod } from '../../../api/measures'; -import { getAllMetrics } from '../../../api/metrics'; import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; +import { useMetrics } from '../../../app/components/metrics/withMetricsContext'; import Suggestions from '../../../components/embed-docs-modal/Suggestions'; import { enhanceMeasure } from '../../../components/measure/utils'; import '../../../components/search-navigator.css'; import AnalysisMissingInfoMessage from '../../../components/shared/AnalysisMissingInfoMessage'; -import { isSameBranchLike } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; -import { areCCTMeasuresComputed } from '../../../helpers/measures'; -import { WithBranchLikesProps, useBranchesQuery } from '../../../queries/branch'; +import { + areCCTMeasuresComputed, + areSoftwareQualityRatingsComputed, +} from '../../../helpers/measures'; +import { useBranchesQuery } from '../../../queries/branch'; +import { useMeasuresComponentQuery } from '../../../queries/measures'; import { MeasurePageView } from '../../../types/measures'; -import { ComponentMeasure, Dict, MeasureEnhanced, Metric, Period } from '../../../types/types'; +import { useBubbleChartMetrics } from '../hooks'; import Sidebar from '../sidebar/Sidebar'; import { Query, @@ -67,123 +67,54 @@ import { sortMeasures, } from '../utils'; import MeasureContent from './MeasureContent'; -import MeasureOverviewContainer from './MeasureOverviewContainer'; +import MeasureOverview from './MeasureOverview'; import MeasuresEmpty from './MeasuresEmpty'; -interface Props extends WithBranchLikesProps { - component: ComponentMeasure; - location: Location; - router: Router; -} - -interface State { - leakPeriod?: Period; - loading: boolean; - measures: MeasureEnhanced[]; - metrics: Dict; -} - -class ComponentMeasuresApp extends React.PureComponent { - mounted = false; - state: State; - - constructor(props: Props) { - super(props); - - this.state = { - loading: true, - measures: [], - metrics: {}, - }; - } - - componentDidMount() { - this.mounted = true; - - getAllMetrics().then( - (metrics) => { - const byKey = keyBy(metrics, 'key'); - this.setState({ metrics: byKey }); - }, - () => {}, +export default function ComponentMeasuresApp() { + const { component } = React.useContext(ComponentContext); + const { data: { branchLike } = {} } = useBranchesQuery(component); + const { query: rawQuery, pathname } = useLocation(); + const query = parseQuery(rawQuery); + const router = useRouter(); + const metrics = useMetrics(); + const filteredMetrics = getMeasuresPageMetricKeys(metrics, branchLike); + const componentKey = + query.selected !== undefined && query.selected !== '' ? query.selected : component?.key ?? ''; + + const { data: { component: componentWithMeasures, period } = {}, isLoading } = + useMeasuresComponentQuery( + { componentKey, metricKeys: filteredMetrics, branchLike }, + { enabled: Boolean(componentKey) }, ); - } - - componentDidUpdate(prevProps: Props, prevState: State) { - const prevQuery = parseQuery(prevProps.location.query); - const query = parseQuery(this.props.location.query); - - const hasSelectedQueryChanged = prevQuery.selected !== query.selected; - - const hasBranchChanged = !isSameBranchLike(prevProps.branchLike, this.props.branchLike); - - const isBranchReady = - isPortfolioLike(this.props.component.qualifier) || this.props.branchLike !== undefined; - const haveMetricsChanged = - Object.keys(this.state.metrics).length !== Object.keys(prevState.metrics).length; - - const areMetricsReady = Object.keys(this.state.metrics).length > 0; - - if ( - areMetricsReady && - isBranchReady && - (haveMetricsChanged || hasBranchChanged || hasSelectedQueryChanged) - ) { - this.fetchMeasures(this.state.metrics); - } + const measures = ( + componentWithMeasures + ? filterMeasures( + banQualityGateMeasure(componentWithMeasures).map((measure) => + enhanceMeasure(measure, metrics), + ), + ) + : [] + ).filter((measure) => measure.value !== undefined || measure.leak !== undefined); + const bubblesByDomain = useBubbleChartMetrics(measures); + + const leakPeriod = + componentWithMeasures?.qualifier === ComponentQualifier.Project ? period : undefined; + const displayOverview = hasBubbleChart(bubblesByDomain, query.metric); + + if (!component) { + return null; } - componentWillUnmount() { - this.mounted = false; - } - - fetchMeasures(metrics: State['metrics']) { - const { branchLike } = this.props; - const query = parseQuery(this.props.location.query); - const componentKey = - query.selected !== undefined && query.selected !== '' - ? query.selected - : this.props.component.key; - - const filteredKeys = getMeasuresPageMetricKeys(metrics, branchLike); - - getMeasuresWithPeriod(componentKey, filteredKeys, getBranchLikeQuery(branchLike)).then( - ({ component, period }) => { - if (this.mounted) { - const measures = filterMeasures( - banQualityGateMeasure(component).map((measure) => enhanceMeasure(measure, metrics)), - ); - const leakPeriod = - component.qualifier === ComponentQualifier.Project ? period : undefined; - - this.setState({ - loading: false, - leakPeriod, - measures: measures.filter( - (measure) => measure.value !== undefined || measure.leak !== undefined, - ), - }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }, - ); - } - - getSelectedMetric = (query: Query, displayOverview: boolean) => { + const getSelectedMetric = (query: Query, displayOverview: boolean) => { if (displayOverview) { return undefined; } - const metric = this.state.metrics[query.metric]; + const metric = metrics[query.metric]; if (!metric) { - const domainMeasures = groupByDomains(this.state.measures); - + const domainMeasures = groupByDomains(measures); const firstMeasure = domainMeasures[0] && sortMeasures(domainMeasures[0].name, domainMeasures[0].measures)[0]; @@ -194,10 +125,11 @@ class ComponentMeasuresApp extends React.PureComponent { return metric; }; - updateQuery = (newQuery: Partial) => { - const query: Query = { ...parseQuery(this.props.location.query), ...newQuery }; + const metric = getSelectedMetric(query, displayOverview); - const metric = this.getSelectedMetric(query, false); + const updateQuery = (newQuery: Partial) => { + const nextQuery: Query = { ...parseQuery(query), ...newQuery }; + const metric = getSelectedMetric(nextQuery, false); if (metric) { if (query.view === MeasurePageView.treemap && !hasTreemap(metric.key, metric.type)) { @@ -207,32 +139,27 @@ class ComponentMeasuresApp extends React.PureComponent { } } - this.props.router.push({ - pathname: this.props.location.pathname, + router.push({ + pathname, query: { - ...serializeQuery(query), - ...getBranchLikeQuery(this.props.branchLike), - id: this.props.component.key, + ...serializeQuery(nextQuery), + ...getBranchLikeQuery(branchLike), + id: component?.key, }, }); }; - renderContent = (displayOverview: boolean, query: Query, metric?: Metric) => { - const { branchLike, component } = this.props; - const { leakPeriod } = this.state; + const showFullMeasures = hasFullMeasures(branchLike); + const renderContent = () => { if (displayOverview) { return ( - ); @@ -261,90 +188,62 @@ class ComponentMeasuresApp extends React.PureComponent { return ( ); }; - render() { - const { branchLike } = this.props; - const { measures } = this.state; - const { canBrowseAllChildProjects, qualifier, key } = this.props.component; - const query = parseQuery(this.props.location.query); - const showFullMeasures = hasFullMeasures(branchLike); - const displayOverview = hasBubbleChart(query.metric); - const metric = this.getSelectedMetric(query, displayOverview); - - return ( - - - - - - - {measures.length > 0 ? ( -
- - -
- {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && ( - - {translate('component_measures.not_all_measures_are_shown')} - - - )} - {!areCCTMeasuresComputed(measures) && ( - - )} - {this.renderContent(displayOverview, query, metric)} -
+ return ( + + + + + + + {measures.length > 0 ? ( +
+ + +
+ {!component?.canBrowseAllChildProjects && isPortfolioLike(component?.qualifier) && ( + + {translate('component_measures.not_all_measures_are_shown')} + + + )} + {(!areCCTMeasuresComputed(measures) || + !areSoftwareQualityRatingsComputed(measures)) && ( + + )} + {renderContent()}
- ) : ( - - - - )} - - - ); - } +
+ ) : ( + + + + )} +
+
+ ); } -/* - * This needs to be refactored: the issue - * is that we can't use the usual withComponentContext HOC, because the type - * of `component` isn't the same. It probably used to work because of the lazy loading - */ -const WrappedApp = withRouter(ComponentMeasuresApp); - -function AppWithComponentContext() { - const { component } = React.useContext(ComponentContext); - const { data: { branchLike } = {} } = useBranchesQuery(component); - - return ; -} - -export default AppWithComponentContext; - const StyledMain = withTheme(styled.main` background-color: ${themeColor('pageBlock')}; border: ${themeBorder('default', 'pageBlockBorder')}; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx index e9b232a4268..dd252561131 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx @@ -17,321 +17,191 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { keepPreviousData } from '@tanstack/react-query'; import { Highlight, KeyboardHint } from 'design-system'; import * as React from 'react'; import A11ySkipTarget from '~sonar-aligned/components/a11y/A11ySkipTarget'; import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like'; import { MetricKey } from '~sonar-aligned/types/metrics'; -import { Router } from '~sonar-aligned/types/router'; -import { getComponentTree } from '../../../api/components'; -import { getMeasures } from '../../../api/measures'; +import { useMetrics } from '../../../app/components/metrics/withMetricsContext'; import SourceViewer from '../../../components/SourceViewer/SourceViewer'; import FilesCounter from '../../../components/ui/FilesCounter'; -import { isSameBranchLike } from '../../../helpers/branch-like'; import { getComponentMeasureUniqueKey } from '../../../helpers/component'; +import { SOFTWARE_QUALITY_RATING_METRICS_MAP } from '../../../helpers/constants'; import { KeyboardKeys } from '../../../helpers/keycodes'; import { translate } from '../../../helpers/l10n'; import { getCCTMeasureValue, isDiffMetric } from '../../../helpers/measures'; import { RequestData } from '../../../helpers/request'; import { isDefined } from '../../../helpers/types'; import { getProjectUrl } from '../../../helpers/urls'; +import { useBranchesQuery } from '../../../queries/branch'; +import { useComponentTreeQuery, useMeasuresComponentQuery } from '../../../queries/measures'; +import { useIsLegacyCCTMode } from '../../../queries/settings'; +import { useLocation, useRouter } from '../../../sonar-aligned/components/hoc/withRouter'; import { BranchLike } from '../../../types/branch-like'; import { isApplication, isFile, isView } from '../../../types/component'; import { MeasurePageView } from '../../../types/measures'; import { - ComponentMeasure, + Component, ComponentMeasureEnhanced, ComponentMeasureIntern, - Dict, - Measure, Metric, - Paging, Period, } from '../../../types/types'; import { complementary } from '../config/complementary'; import FilesView from '../drilldown/FilesView'; import TreeMapView from '../drilldown/TreeMapView'; -import { Query, enhanceComponent } from '../utils'; +import { Query, enhanceComponent, parseQuery } from '../utils'; import MeasureContentHeader from './MeasureContentHeader'; import MeasureHeader from './MeasureHeader'; import MeasureViewSelect from './MeasureViewSelect'; import MeasuresBreadcrumbs from './MeasuresBreadcrumbs'; interface Props { - asc?: boolean; - branchLike?: BranchLike; leakPeriod?: Period; - metrics: Dict; requestedMetric: Pick; - rootComponent: ComponentMeasure; - router: Router; - selected?: string; + rootComponent: Component; updateQuery: (query: Partial) => void; - view: MeasurePageView; } -interface State { - baseComponent?: ComponentMeasure; - components: ComponentMeasureEnhanced[]; - loadingMoreComponents: boolean; - measure?: Measure; - metric?: Metric; - paging?: Paging; - secondaryMeasure?: Measure; - selectedComponent?: ComponentMeasureIntern; -} - -export default class MeasureContent extends React.PureComponent { - container?: HTMLElement | null; - mounted = false; - state: State = { - components: [], - loadingMoreComponents: false, - }; - - componentDidMount() { - this.mounted = true; - this.fetchComponentTree(); +export default function MeasureContent(props: Readonly) { + const { leakPeriod, requestedMetric, rootComponent, updateQuery } = props; + const metrics = useMetrics(); + const { query: rawQuery } = useLocation(); + const { data: { branchLike } = {} } = useBranchesQuery(); + const router = useRouter(); + const query = parseQuery(rawQuery); + const { data: isLegacy } = useIsLegacyCCTMode(); + const { selected, asc, view } = query; + + const containerRef = React.useRef(null); + // if asc is undefined we dont want to pass it inside options + const { metricKeys, opts, strategy } = getComponentRequestParams( + view, + requestedMetric, + branchLike, + { + ...(asc !== undefined && { asc }), + }, + ); + const componentKey = selected !== undefined && selected !== '' ? selected : rootComponent.key; + const { + data: treeData, + isFetchingNextPage: fetchingMoreComponents, + fetchNextPage, + } = useComponentTreeQuery( + { + strategy, + component: componentKey, + metrics: metricKeys, + additionalData: opts, + }, + { + placeholderData: keepPreviousData, + }, + ); + + const baseComponentMetrics = [requestedMetric.key]; + + if (requestedMetric.key === MetricKey.ncloc) { + baseComponentMetrics.push(MetricKey.ncloc_language_distribution); } - - componentDidUpdate(prevProps: Props) { - const prevComponentKey = - prevProps.selected !== undefined && prevProps.selected !== '' - ? prevProps.selected - : prevProps.rootComponent.key; - const componentKey = - this.props.selected !== undefined && this.props.selected !== '' - ? this.props.selected - : this.props.rootComponent.key; - if ( - prevComponentKey !== componentKey || - !isSameBranchLike(prevProps.branchLike, this.props.branchLike) || - prevProps.requestedMetric !== this.props.requestedMetric || - prevProps.view !== this.props.view - ) { - this.fetchComponentTree(); - } + if (SOFTWARE_QUALITY_RATING_METRICS_MAP[requestedMetric.key]) { + baseComponentMetrics.push(SOFTWARE_QUALITY_RATING_METRICS_MAP[requestedMetric.key]); } - componentWillUnmount() { - this.mounted = false; - } + const { data: measuresData } = useMeasuresComponentQuery( + { componentKey, metricKeys: baseComponentMetrics, branchLike }, + { enabled: Boolean(componentKey) }, + ); - fetchComponentTree = () => { - const { asc, branchLike, metrics, requestedMetric, rootComponent, selected, view } = this.props; - // if asc is undefined we dont want to pass it inside options - const { metricKeys, opts, strategy } = this.getComponentRequestParams(view, requestedMetric, { - ...(asc !== undefined && { asc }), - }); - const componentKey = selected !== undefined && selected !== '' ? selected : rootComponent.key; - const baseComponentMetrics = [requestedMetric.key]; - if (requestedMetric.key === MetricKey.ncloc) { - baseComponentMetrics.push(MetricKey.ncloc_language_distribution); - } - Promise.all([ - getComponentTree(strategy, componentKey, metricKeys, opts), - getMeasures({ - component: componentKey, - metricKeys: baseComponentMetrics.join(), - ...getBranchLikeQuery(branchLike), - }), - ]).then( - ([tree, measures]) => { - if (this.mounted) { - const metric = tree.metrics.find((m) => m.key === requestedMetric.key); - if (metric !== undefined) { - metric.direction = requestedMetric.direction; - } - - const components = tree.components.map((component) => - enhanceComponent(component, metric, metrics), - ); + const [selectedComponent, setSelectedComponent] = React.useState(); - const measure = measures.find((m) => m.metric === requestedMetric.key); - const secondaryMeasure = measures.find((m) => m.metric !== requestedMetric.key); + const metric = metrics[requestedMetric.key]; + metric.direction = requestedMetric.direction; - this.setState(({ selectedComponent }) => ({ - baseComponent: tree.baseComponent, - components, - measure, - metric, - paging: tree.paging, - secondaryMeasure, - selectedComponent: - components.length > 0 && - components.find( - (c) => - getComponentMeasureUniqueKey(c) === - getComponentMeasureUniqueKey(selectedComponent), - ) - ? selectedComponent - : undefined, - })); - } - }, - () => { - /* noop */ - }, - ); - }; + const baseComponent = treeData?.pages[0].baseComponent; + if (!baseComponent) { + return null; + } - fetchMoreComponents = () => { - const { metrics, view, asc } = this.props; - const { baseComponent, metric, paging } = this.state; - if (!baseComponent || !paging || !metric) { - return; - } - // if asc is undefined we dont want to pass it inside options - const { metricKeys, opts, strategy } = this.getComponentRequestParams(view, metric, { - p: paging.pageIndex + 1, - ...(asc !== undefined && { asc }), - }); - this.setState({ loadingMoreComponents: true }); - getComponentTree(strategy, baseComponent.key, metricKeys, opts).then( - (r) => { - if (this.mounted && metric.key === this.props.requestedMetric.key) { - this.setState((state) => ({ - components: [ - ...state.components, - ...r.components.map((component) => enhanceComponent(component, metric, metrics)), - ], - loadingMoreComponents: false, - paging: r.paging, - })); - } - }, - () => { - if (this.mounted) { - this.setState({ loadingMoreComponents: false }); - } - }, + const components = + treeData?.pages + .flatMap((p) => p.components) + .map((component) => enhanceComponent(component, metric, metrics)) ?? []; + + const measures = measuresData?.component.measures ?? []; + const measure = measures.find((m) => m.metric === requestedMetric.key); + const secondaryMeasure = measures.find((m) => m.metric !== requestedMetric.key); + const rawMeasureValue = + measure && (isDiffMetric(measure.metric) ? measure.period?.value : measure.value); + const measureValue = getCCTMeasureValue(metric.key, rawMeasureValue); + const isFileComponent = isFile(baseComponent.qualifier); + + const paging = treeData?.pages[treeData?.pages.length - 1].paging; + const totalComponents = treeData?.pages[0].paging.total; + + const getSelectedIndex = () => { + const componentKey = isFile(baseComponent?.qualifier) + ? getComponentMeasureUniqueKey(baseComponent) + : getComponentMeasureUniqueKey(selectedComponent); + const index = components.findIndex( + (component) => getComponentMeasureUniqueKey(component) === componentKey, ); + return index !== -1 ? index : undefined; }; + const selectedIdx = getSelectedIndex(); - getComponentRequestParams( - view: MeasurePageView, - metric: Pick, - options: Object = {}, - ) { - const strategy = view === MeasurePageView.list ? 'leaves' : 'children'; - const metricKeys = [metric.key]; - const opts: RequestData = { - ...getBranchLikeQuery(this.props.branchLike), - additionalFields: 'metrics', - ps: 500, - }; - - const setMetricSort = () => { - const isDiff = isDiffMetric(metric.key); - opts.s = isDiff ? 'metricPeriod' : 'metric'; - opts.metricSortFilter = 'withMeasuresOnly'; - if (isDiff) { - opts.metricPeriodSort = 1; - } - }; - - const isDiff = isDiffMetric(metric.key); - if (view === MeasurePageView.tree) { - metricKeys.push(...(complementary[metric.key] || [])); - opts.asc = true; - opts.s = 'qualifier,name'; - } else if (view === MeasurePageView.list) { - metricKeys.push(...(complementary[metric.key] || [])); - opts.asc = metric.direction === 1; - opts.metricSort = metric.key; - setMetricSort(); - } else if (view === MeasurePageView.treemap) { - const sizeMetric = isDiff ? MetricKey.new_lines : MetricKey.ncloc; - metricKeys.push(sizeMetric); - opts.asc = false; - opts.metricSort = sizeMetric; - setMetricSort(); - } - - return { metricKeys, opts: { ...opts, ...options }, strategy }; - } - - updateSelected = (component: string) => { - this.props.updateQuery({ - selected: component !== this.props.rootComponent.key ? component : undefined, + const updateSelected = (component: string) => { + updateQuery({ + selected: component !== rootComponent.key ? component : undefined, }); }; - updateView = (view: MeasurePageView) => { - this.props.updateQuery({ view }); - }; - - onOpenComponent = (component: ComponentMeasureIntern) => { - if (isView(this.props.rootComponent.qualifier)) { - const comp = this.state.components.find( + const onOpenComponent = (component: ComponentMeasureIntern) => { + if (isView(rootComponent.qualifier)) { + const comp = components.find( (c) => c.refKey === component.key || getComponentMeasureUniqueKey(c) === getComponentMeasureUniqueKey(component), ); if (comp) { - this.props.router.push(getProjectUrl(comp.refKey || comp.key, component.branch)); + router.push(getProjectUrl(comp.refKey ?? comp.key, component.branch)); } return; } - this.setState((state) => ({ selectedComponent: state.baseComponent })); - this.updateSelected(component.key); - if (this.container) { - this.container.focus(); + updateSelected(component.key); + if (containerRef.current) { + containerRef.current.focus(); } }; - onSelectComponent = (component: ComponentMeasureIntern) => { - this.setState({ selectedComponent: component }); - }; - - getSelectedIndex = () => { - const componentKey = isFile(this.state.baseComponent?.qualifier) - ? getComponentMeasureUniqueKey(this.state.baseComponent) - : getComponentMeasureUniqueKey(this.state.selectedComponent); - const index = this.state.components.findIndex( - (component) => getComponentMeasureUniqueKey(component) === componentKey, - ); - return index !== -1 ? index : undefined; + const handleSelectRow = (component: ComponentMeasureEnhanced) => { + setSelectedComponent(component); }; - getDefaultShowBestMeasures() { - const { asc, view } = this.props; - if ((asc !== undefined && view === MeasurePageView.list) || view === MeasurePageView.tree) { - return true; - } - return false; - } - - renderMeasure() { - const { view } = this.props; - const { metric } = this.state; - if (!metric) { - return null; - } + const renderMeasure = () => { if (view === MeasurePageView.list || view === MeasurePageView.tree) { - const selectedIdx = this.getSelectedIndex(); return ( ); @@ -339,119 +209,150 @@ export default class MeasureContent extends React.PureComponent { return ( ); - } - - render() { - const { branchLike, rootComponent, view } = this.props; - const { baseComponent, measure, metric, paging, secondaryMeasure } = this.state; - - if (!baseComponent || !metric) { - return null; - } + }; - const rawMeasureValue = - measure && (isDiffMetric(measure.metric) ? measure.period?.value : measure.value); - const measureValue = getCCTMeasureValue(metric.key, rawMeasureValue); + return ( +
+ - const isFileComponent = isFile(baseComponent.qualifier); - const selectedIdx = this.getSelectedIndex(); + + } + right={ +
+ {!isFileComponent && metric && ( + <> + {!isApplication(baseComponent.qualifier) && ( + <> + + {translate('component_measures.view_as')} + + updateQuery({ view })} + metric={metric} + view={view} + /> + + )} + + {view !== MeasurePageView.treemap && ( + <> + - return ( -
(this.container = container)}> - + + + )} + + {isDefined(totalComponents) && totalComponents > 0 && ( + + )} + + )} +
+ } + /> - + + {isFileComponent ? ( +
+ - } - right={ -
- {!isFileComponent && metric && ( - <> - {!isApplication(baseComponent.qualifier) && ( - <> - - {translate('component_measures.view_as')} - - - - )} - - {view !== MeasurePageView.treemap && ( - <> - +
+ ) : ( + renderMeasure() + )} +
+
+ ); +} - - - )} +function getComponentRequestParams( + view: MeasurePageView, + metric: Pick, + branchLike?: BranchLike, + options: Object = {}, +) { + const strategy: 'leaves' | 'children' = view === MeasurePageView.list ? 'leaves' : 'children'; + const metricKeys = [metric.key]; + const softwareQualityRatingMetric = SOFTWARE_QUALITY_RATING_METRICS_MAP[metric.key]; + if (softwareQualityRatingMetric) { + metricKeys.push(softwareQualityRatingMetric); + } + const opts: RequestData = { + ...getBranchLikeQuery(branchLike), + additionalFields: 'metrics', + ps: 500, + }; - {paging && paging.total > 0 && ( - - )} - - )} -
- } - /> + const setMetricSort = () => { + const isDiff = isDiffMetric(metric.key); + opts.s = isDiff ? 'metricPeriod' : 'metric'; + opts.metricSortFilter = 'withMeasuresOnly'; + if (isDiff) { + opts.metricPeriodSort = 1; + } + }; -
- - {isFileComponent ? ( -
- -
- ) : ( - this.renderMeasure() - )} -
-
- ); + const isDiff = isDiffMetric(metric.key); + if (view === MeasurePageView.tree) { + metricKeys.push(...(complementary[metric.key] || [])); + opts.asc = true; + opts.s = 'qualifier,name'; + } else if (view === MeasurePageView.list) { + metricKeys.push(...(complementary[metric.key] || [])); + opts.asc = metric.direction === 1; + opts.metricSort = metric.key; + setMetricSort(); + } else if (view === MeasurePageView.treemap) { + const sizeMetric = isDiff ? MetricKey.new_lines : MetricKey.ncloc; + metricKeys.push(...(complementary[metric.key] || [])); + metricKeys.push(sizeMetric); + opts.asc = false; + opts.metricSort = sizeMetric; + setMetricSort(); } + + return { metricKeys, opts: { ...opts, ...options }, strategy }; } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.tsx index ad7ace64e8b..ccece093bb7 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHeader.tsx @@ -63,6 +63,7 @@ export default function MeasureHeader(props: Readonly) {
; - rootComponent: ComponentMeasure; - updateLoading: (param: Dict) => void; - updateSelected: (component: ComponentMeasureIntern) => void; -} - -interface State { - components: ComponentMeasureEnhanced[]; - paging?: Paging; + rootComponent: Component; + updateQuery: (query: Partial) => void; } -export default class MeasureOverview extends React.PureComponent { - mounted = false; - state: State = { components: [] }; +export default function MeasureOverview(props: Readonly) { + const { leakPeriod, updateQuery, rootComponent, bubblesByDomain } = props; + const metrics = useMetrics(); + const { data: { branchLike } = {} } = useBranchesQuery(); + const router = useRouter(); + const { query } = useLocation(); + const { selected, metric: domain } = parseQuery(query); + // eslint-disable-next-line local-rules/no-implicit-coercion + const componentKey = selected || rootComponent.key; + const { data: componentData, isLoading: loadingComponent } = useComponentDataQuery( + { + ...getBranchLikeQuery(branchLike), + component: componentKey, + }, + { enabled: Boolean(componentKey) }, + ); - componentDidMount() { - this.mounted = true; - this.fetchComponents(); + const component = componentData?.component; + const { x, y, size, colors } = getBubbleMetrics(bubblesByDomain, domain, metrics); + const metricsKey = [x.key, y.key, size.key]; + if (colors) { + metricsKey.push(...colors.map((metric) => metric.key)); } + const { data: bubblesData, isLoading: loadingBubbles } = useComponentTreeQuery( + { + strategy: 'leaves', + metrics: metricsKey, + component: component?.key ?? '', + additionalData: { + ...getBranchLikeQuery(branchLike), + s: 'metric', + metricSort: size.key, + asc: false, + ps: BUBBLES_FETCH_LIMIT, + }, + }, + { + enabled: Boolean(component), + }, + ); - componentDidUpdate(prevProps: Props) { - if ( - prevProps.component !== this.props.component || - !isSameBranchLike(prevProps.branchLike, this.props.branchLike) || - prevProps.metrics !== this.props.metrics || - prevProps.domain !== this.props.domain - ) { - this.fetchComponents(); - } - } + const components = (bubblesData?.pages?.[0]?.components ?? []).map((c) => + enhanceComponent(c, undefined, metrics), + ); + const paging = bubblesData?.pages?.[0]?.paging; - componentWillUnmount() { - this.mounted = false; + if (!component) { + return null; } - fetchComponents = () => { - const { branchLike, component, domain, metrics } = this.props; - if (isFile(component.qualifier)) { - this.setState({ components: [], paging: undefined }); - return; - } - const { x, y, size, colors } = getBubbleMetrics(domain, metrics); - const metricsKey = [x.key, y.key, size.key]; - if (colors) { - metricsKey.push(...colors.map((metric) => metric.key)); - } - const options = { - ...getBranchLikeQuery(branchLike), - s: 'metric', - metricSort: size.key, - asc: false, - ps: BUBBLES_FETCH_LIMIT, - }; + const loading = loadingComponent || loadingBubbles; - this.props.updateLoading({ bubbles: true }); - getComponentLeaves(component.key, metricsKey, options).then( - (r) => { - if (domain === this.props.domain) { - if (this.mounted) { - this.setState({ - components: r.components.map((c) => enhanceComponent(c, undefined, metrics)), - paging: r.paging, - }); - } - this.props.updateLoading({ bubbles: false }); - } - }, - () => this.props.updateLoading({ bubbles: false }), - ); + const updateSelected = (component: ComponentMeasureIntern) => { + if (component && isView(component.qualifier)) { + router.push(getProjectUrl(component.refKey || component.key, component.branch)); + } else { + updateQuery({ + selected: component.key !== rootComponent.key ? component.key : undefined, + }); + } }; + const displayLeak = hasFullMeasures(branchLike); + const isFileComponent = isFile(component.qualifier); - renderContent(isFile: boolean) { - const { branchLike, component, domain, metrics } = this.props; - const { paging } = this.state; - - if (isFile) { - return ( -
- -
- ); - } + return ( +
+ - return ( - + } + right={ + leakPeriod && + displayLeak && + } /> - ); - } - render() { - const { branchLike, className, component, leakPeriod, loading, rootComponent } = this.props; - const displayLeak = hasFullMeasures(branchLike); - const isFileComponent = isFile(component.qualifier); - - return ( -
- - - + + {isFileComponent && ( +
+ +
+ )} + {!isFileComponent && ( + - } - right={ - leakPeriod && - displayLeak && - } - /> - -
- - {!loading && this.renderContent(isFileComponent)} -
+ )} +
- ); - } +
+ ); } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx deleted file mode 100644 index 9455a77c612..00000000000 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2024 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 * as React from 'react'; -import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like'; -import { Router } from '~sonar-aligned/types/router'; -import { getComponentShow } from '../../../api/components'; -import { isSameBranchLike } from '../../../helpers/branch-like'; -import { getProjectUrl } from '../../../helpers/urls'; -import { BranchLike } from '../../../types/branch-like'; -import { isView } from '../../../types/component'; -import { - ComponentMeasure, - ComponentMeasureIntern, - Dict, - Metric, - Period, -} from '../../../types/types'; -import { Query } from '../utils'; -import MeasureOverview from './MeasureOverview'; - -interface Props { - branchLike?: BranchLike; - className?: string; - domain: string; - leakPeriod?: Period; - metrics: Dict; - rootComponent: ComponentMeasure; - router: Router; - selected?: string; - updateQuery: (query: Partial) => void; -} - -interface LoadingState { - bubbles: boolean; - component: boolean; -} - -interface State { - component?: ComponentMeasure; - loading: LoadingState; -} - -export default class MeasureOverviewContainer extends React.PureComponent { - mounted = false; - - state: State = { - loading: { bubbles: false, component: false }, - }; - - componentDidMount() { - this.mounted = true; - this.fetchComponent(); - } - - componentDidUpdate(prevProps: Props) { - const prevComponentKey = prevProps.selected || prevProps.rootComponent.key; - const componentKey = this.props.selected || this.props.rootComponent.key; - if ( - prevComponentKey !== componentKey || - !isSameBranchLike(prevProps.branchLike, this.props.branchLike) || - prevProps.domain !== this.props.domain - ) { - this.fetchComponent(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - fetchComponent = () => { - const { branchLike, rootComponent, selected } = this.props; - if (!selected || rootComponent.key === selected) { - this.setState({ component: rootComponent }); - this.updateLoading({ component: false }); - return; - } - this.updateLoading({ component: true }); - getComponentShow({ component: selected, ...getBranchLikeQuery(branchLike) }).then( - ({ component }) => { - if (this.mounted) { - this.setState({ component }); - this.updateLoading({ component: false }); - } - }, - () => this.updateLoading({ component: false }), - ); - }; - - updateLoading = (loading: Partial) => { - if (this.mounted) { - this.setState((state) => ({ loading: { ...state.loading, ...loading } })); - } - }; - - updateSelected = (component: ComponentMeasureIntern) => { - if (this.state.component && isView(this.state.component.qualifier)) { - this.props.router.push(getProjectUrl(component.refKey || component.key, component.branch)); - } else { - this.props.updateQuery({ - selected: component.key !== this.props.rootComponent.key ? component.key : undefined, - }); - } - }; - - render() { - if (!this.state.component) { - return null; - } - - return ( - - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasuresBreadcrumbs.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasuresBreadcrumbs.tsx index 0284d8bc4a1..28648e7acd1 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasuresBreadcrumbs.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasuresBreadcrumbs.tsx @@ -29,7 +29,7 @@ import { translate } from '../../../helpers/l10n'; import { collapsePath, limitComponentName } from '../../../helpers/path'; import { BranchLike } from '../../../types/branch-like'; import { isProject } from '../../../types/component'; -import { ComponentMeasure, ComponentMeasureIntern } from '../../../types/types'; +import { Component, ComponentMeasure, ComponentMeasureIntern } from '../../../types/types'; interface Props { backToFirst: boolean; @@ -37,7 +37,7 @@ interface Props { className?: string; component: ComponentMeasure; handleSelect: (component: ComponentMeasureIntern) => void; - rootComponent: ComponentMeasure; + rootComponent: Component; } interface State { diff --git a/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.ts b/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.ts index f660bda32db..a8e55997174 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.ts +++ b/server/sonar-web/src/main/js/apps/component-measures/config/bubbles.ts @@ -19,15 +19,96 @@ */ import { MetricKey } from '~sonar-aligned/types/metrics'; -export const bubbles: { - [domain: string]: { +export type BubblesByDomain = Record< + string, + { colors?: string[]; size: string; x: string; y: string; yDomain?: [number, number]; - }; -} = { + } +>; + +export const newTaxonomyBubbles: BubblesByDomain = { + Reliability: { + x: MetricKey.ncloc, + y: MetricKey.reliability_remediation_effort, + size: MetricKey.reliability_issues, + colors: [MetricKey.reliability_rating_new], + }, + Security: { + x: MetricKey.ncloc, + y: MetricKey.security_remediation_effort, + size: MetricKey.security_issues, + colors: [MetricKey.security_rating_new], + }, + Maintainability: { + x: MetricKey.ncloc, + y: MetricKey.sqale_index, + size: MetricKey.maintainability_issues, + colors: [MetricKey.sqale_rating_new], + }, + Coverage: { + x: MetricKey.complexity, + y: MetricKey.coverage, + size: MetricKey.uncovered_lines, + yDomain: [100, 0], + }, + Duplications: { + x: MetricKey.ncloc, + y: MetricKey.duplicated_lines, + size: MetricKey.duplicated_blocks, + }, + project_overview: { + x: MetricKey.sqale_index, + y: MetricKey.coverage, + size: MetricKey.ncloc, + colors: [MetricKey.reliability_rating_new, MetricKey.security_rating_new], + yDomain: [100, 0], + }, +}; + +export const newTaxonomyWithoutRatingsBubbles: BubblesByDomain = { + Reliability: { + x: MetricKey.ncloc, + y: MetricKey.reliability_remediation_effort, + size: MetricKey.reliability_issues, + colors: [MetricKey.reliability_rating], + }, + Security: { + x: MetricKey.ncloc, + y: MetricKey.security_remediation_effort, + size: MetricKey.security_issues, + colors: [MetricKey.security_rating], + }, + Maintainability: { + x: MetricKey.ncloc, + y: MetricKey.sqale_index, + size: MetricKey.maintainability_issues, + colors: [MetricKey.sqale_rating], + }, + Coverage: { + x: MetricKey.complexity, + y: MetricKey.coverage, + size: MetricKey.uncovered_lines, + yDomain: [100, 0], + }, + Duplications: { + x: MetricKey.ncloc, + y: MetricKey.duplicated_lines, + size: MetricKey.duplicated_blocks, + }, + project_overview: { + x: MetricKey.sqale_index, + y: MetricKey.coverage, + size: MetricKey.ncloc, + colors: [MetricKey.reliability_rating, MetricKey.security_rating], + yDomain: [100, 0], + }, +}; + +export const legacyBubbles: BubblesByDomain = { Reliability: { x: MetricKey.ncloc, y: MetricKey.reliability_remediation_effort, diff --git a/server/sonar-web/src/main/js/apps/component-measures/config/domains.ts b/server/sonar-web/src/main/js/apps/component-measures/config/domains.ts index 6eb98c969f2..a98910ded13 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/config/domains.ts +++ b/server/sonar-web/src/main/js/apps/component-measures/config/domains.ts @@ -34,13 +34,17 @@ export const domains: Domains = { MetricKey.new_reliability_issues, MetricKey.new_bugs, MetricKey.new_reliability_rating, + MetricKey.new_reliability_rating_new, MetricKey.new_reliability_remediation_effort, + MetricKey.new_reliability_remediation_effort_new, OVERALL_CATEGORY, MetricKey.reliability_issues, MetricKey.bugs, MetricKey.reliability_rating, + MetricKey.reliability_rating_new, MetricKey.reliability_remediation_effort, + MetricKey.reliability_remediation_effort_new, ], }, @@ -51,13 +55,17 @@ export const domains: Domains = { MetricKey.new_security_issues, MetricKey.new_vulnerabilities, MetricKey.new_security_rating, + MetricKey.new_security_rating_new, MetricKey.new_security_remediation_effort, + MetricKey.new_security_remediation_effort_new, OVERALL_CATEGORY, MetricKey.security_issues, MetricKey.vulnerabilities, MetricKey.security_rating, + MetricKey.security_rating_new, MetricKey.security_remediation_effort, + MetricKey.security_remediation_effort_new, ], }, @@ -67,11 +75,13 @@ export const domains: Domains = { NEW_CODE_CATEGORY, MetricKey.new_security_hotspots, MetricKey.new_security_review_rating, + MetricKey.new_security_review_rating_new, MetricKey.new_security_hotspots_reviewed, OVERALL_CATEGORY, MetricKey.security_hotspots, MetricKey.security_review_rating, + MetricKey.security_review_rating_new, MetricKey.security_hotspots_reviewed, ], }, @@ -85,6 +95,7 @@ export const domains: Domains = { MetricKey.new_technical_debt, MetricKey.new_sqale_debt_ratio, MetricKey.new_maintainability_rating, + MetricKey.new_maintainability_rating_new, OVERALL_CATEGORY, MetricKey.maintainability_issues, @@ -92,7 +103,9 @@ export const domains: Domains = { MetricKey.sqale_index, MetricKey.sqale_debt_ratio, MetricKey.sqale_rating, + MetricKey.sqale_rating_new, MetricKey.effort_to_reach_maintainability_rating_a, + MetricKey.effort_to_reach_maintainability_rating_a_new, ], }, 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 index aac6a045d29..1e434ec3079 100644 --- 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 @@ -17,6 +17,7 @@ * 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 { BubbleColorVal, @@ -25,6 +26,7 @@ import { Link, BubbleChart as OriginalBubbleChart, themeColor, + themeContrast, } from 'design-system'; import * as React from 'react'; import HelpTooltip from '~sonar-aligned/components/controls/HelpTooltip'; @@ -36,9 +38,10 @@ import { translate, translateWithParameters, } from '../../../helpers/l10n'; -import { isDiffMetric } from '../../../helpers/measures'; +import { getCCTMeasureValue, isDiffMetric } from '../../../helpers/measures'; import { isDefined } from '../../../helpers/types'; import { getComponentDrilldownUrl } from '../../../helpers/urls'; +import { useIsLegacyCCTMode } from '../../../queries/settings'; import { BranchLike } from '../../../types/branch-like'; import { isProject, isView } from '../../../types/component'; import { @@ -49,6 +52,7 @@ import { Metric, Paging, } from '../../../types/types'; +import { BubblesByDomain } from '../config/bubbles'; import { BUBBLES_FETCH_LIMIT, getBubbleMetrics, @@ -62,6 +66,7 @@ const HEIGHT = 500; interface Props { branchLike?: BranchLike; + bubblesByDomain: BubblesByDomain; component: ComponentMeasureI; components: ComponentMeasureEnhanced[]; domain: string; @@ -70,84 +75,35 @@ interface Props { updateSelected: (component: ComponentMeasureIntern) => void; } -interface State { - ratingFilters: { [rating: number]: boolean }; -} - -export default class BubbleChartView extends React.PureComponent { - state: State = { - ratingFilters: {}, - }; +export default function BubbleChartView(props: Readonly) { + const { + metrics, + domain, + components, + updateSelected, + paging, + component, + branchLike, + bubblesByDomain, + } = props; + const theme = useTheme(); + const { data: isLegacy } = useIsLegacyCCTMode(); + const bubbleMetrics = getBubbleMetrics(bubblesByDomain, domain, metrics); + const [ratingFilters, setRatingFilters] = React.useState<{ [rating: number]: boolean }>({}); - 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: { colors?: Array; size: number; x: number; y: number }, - metrics: { colors?: Metric[]; size: Metric; x: Metric; y: 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] } }; + const handleRatingFilterClick = (selection: number) => { + setRatingFilters((ratingFilters) => { + return { ...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: { colors?: Metric[]; size: Metric; x: Metric; y: Metric }) { - const { ratingFilters } = this.state; - - const items = this.props.components + const renderBubbleChart = () => { + const items = 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)); + const x = getMeasureVal(component, bubbleMetrics.x); + const y = getMeasureVal(component, bubbleMetrics.y); + const size = getMeasureVal(component, bubbleMetrics.size); + const colors = bubbleMetrics.colors?.map((metric) => getMeasureVal(component, metric)); if ((!x && x !== 0) || (!y && y !== 0) || (!size && size !== 0)) { return undefined; } @@ -163,15 +119,26 @@ export default class BubbleChartView extends React.PureComponent { x, y, size, - color: (colorRating as BubbleColorVal) ?? 0, + backgroundColor: themeColor( + `bubble.${isLegacy ? 'legacy.' : ''}${colorRating as BubbleColorVal}`, + )({ + theme, + }), + borderColor: themeContrast( + `bubble.${isLegacy ? 'legacy.' : ''}${colorRating as BubbleColorVal}`, + )({ + theme, + }), data: component, - tooltip: this.getTooltip(component, { x, y, size, colors }, metrics), + tooltip: getTooltip(component, { x, y, size, colors }, bubbleMetrics), }; }) .filter(isDefined); - const formatXTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.x.type); - const formatYTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.y.type); + const formatXTick = (tick: string | number | undefined) => + formatMeasure(tick, bubbleMetrics.x.type); + const formatYTick = (tick: string | number | undefined) => + formatMeasure(tick, bubbleMetrics.y.type); let xDomain: [number, number] | undefined; if (items.reduce((acc, item) => acc + item.x, 0) === 0) { @@ -188,19 +155,15 @@ export default class BubbleChartView extends React.PureComponent { formatYTick={formatYTick} height={HEIGHT} items={items} - onBubbleClick={this.handleBubbleClick} + onBubbleClick={(component: ComponentMeasureEnhanced) => updateSelected(component)} padding={[0, 4, 50, 100]} - yDomain={getBubbleYDomain(this.props.domain)} + yDomain={getBubbleYDomain(bubblesByDomain, 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 renderChartHeader = () => { const title = isProjectOverview(domain) ? translate('component_measures.overview', domain, 'title') : translateWithParameters( @@ -213,7 +176,7 @@ export default class BubbleChartView extends React.PureComponent {
{title} - +
@@ -229,7 +192,7 @@ export default class BubbleChartView extends React.PureComponent { to={getComponentDrilldownUrl({ componentKey: component.key, branchLike, - metric: isProjectOverview(domain) ? MetricKey.violations : metrics.size.key, + metric: isProjectOverview(domain) ? MetricKey.violations : bubbleMetrics.size.key, listView: true, })} > @@ -241,55 +204,105 @@ export default class BubbleChartView extends React.PureComponent {
- {colorsMetric && ( + {bubbleMetrics.colors && ( {translate('component_measures.legend.color')} {' '} - {colorsMetric.length > 1 + {bubbleMetrics.colors.length > 1 ? translateWithParameters( 'component_measures.legend.worse_of_x_y', - ...colorsMetric.map((metric) => getLocalizedMetricName(metric)), + ...bubbleMetrics.colors.map((metric) => getLocalizedMetricName(metric)), ) - : getLocalizedMetricName(colorsMetric[0])} + : getLocalizedMetricName(bubbleMetrics.colors[0])} )} {translate('component_measures.legend.size')} {' '} - {getLocalizedMetricName(sizeMetric)} + {getLocalizedMetricName(bubbleMetrics.size)}
- {colorsMetric && ( + {bubbleMetrics.colors && ( )}
); + }; + + if (components.length <= 0) { + return ; } - render() { - if (this.props.components.length <= 0) { - return ; - } - const { domain } = this.props; - const metrics = getBubbleMetrics(domain, this.props.metrics); + return ( + + {renderChartHeader()} + {renderBubbleChart()} +
{getLocalizedMetricName(bubbleMetrics.x)}
+ + {getLocalizedMetricName(bubbleMetrics.y)} + +
+ ); +} - return ( - - {this.renderChartHeader(domain, metrics.size, metrics.colors)} - {this.renderBubbleChart(metrics)} -
{getLocalizedMetricName(metrics.x)}
- - {getLocalizedMetricName(metrics.y)} - -
- ); +const getDescription = (domain: string) => { + const description = `component_measures.overview.${domain}.description`; + const translatedDescription = translate(description); + if (description === translatedDescription) { + return null; } -} + return translatedDescription; +}; + +const getMeasureVal = (component: ComponentMeasureEnhanced, metric: Metric) => { + const measure = component.measures.find((measure) => measure.metric.key === metric.key); + if (!measure) { + return undefined; + } + return Number( + getCCTMeasureValue(metric.key, isDiffMetric(metric.key) ? measure.leak : measure.value), + ); +}; + +const getTooltip = ( + component: ComponentMeasureEnhanced, + values: { colors?: Array; size: number; x: number; y: number }, + metrics: { colors?: Metric[]; size: Metric; x: Metric; y: 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 &&
} +
+ ))} +
+ ); +}; const BubbleChartWrapper = styled.div` color: ${themeColor('pageContentLight')}; 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 index 7633c1cdf0c..5919e1c6842 100644 --- 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 @@ -17,11 +17,19 @@ * 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 { useTheme } from '@emotion/react'; +import { + BubbleColorVal, + ColorFilterOption, + ColorsLegend, + themeColor, + themeContrast, +} from 'design-system'; import * as React from 'react'; import { formatMeasure } from '~sonar-aligned/helpers/measures'; import { MetricType } from '~sonar-aligned/types/metrics'; import { translateWithParameters } from '../../../helpers/l10n'; +import { useIsLegacyCCTMode } from '../../../queries/settings'; export interface ColorRatingsLegendProps { className?: string; @@ -29,12 +37,14 @@ export interface ColorRatingsLegendProps { onRatingClick: (selection: number) => void; } -const RATINGS = [1, 2, 3, 4, 5]; - export default function ColorRatingsLegend(props: ColorRatingsLegendProps) { + const { data: isLegacy } = useIsLegacyCCTMode(); + const theme = useTheme(); + const RATINGS = isLegacy ? [1, 2, 3, 4, 5] : [1, 2, 3, 4]; + const { className, filters } = props; - const ratingsColors = RATINGS.map((rating) => { + const ratingsColors = RATINGS.map((rating: BubbleColorVal) => { const formattedMeasure = formatMeasure(rating, MetricType.Rating); return { overlay: translateWithParameters('component_measures.legend.help_x', formattedMeasure), @@ -42,6 +52,12 @@ export default function ColorRatingsLegend(props: ColorRatingsLegendProps) { label: formattedMeasure, value: rating, selected: !filters[rating], + backgroundColor: themeColor(isLegacy ? `bubble.legacy.${rating}` : `bubble.${rating}`)({ + theme, + }), + borderColor: themeContrast(isLegacy ? `bubble.legacy.${rating}` : `bubble.${rating}`)({ + theme, + }), }; }); diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.tsx index affaad2fb09..b52c5f101cf 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsList.tsx @@ -79,10 +79,11 @@ export default function ComponentsList({ components, metric, metrics, ...props } view={props.view} /> - + {otherMetrics.map((metric) => ( measure.metric.key === metric.key)} diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/FilesView.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/FilesView.tsx index d358d8c6d1a..2ef61788977 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/FilesView.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/FilesView.tsx @@ -30,13 +30,7 @@ import { translate, translateWithParameters } from '../../../helpers/l10n'; import { isDiffMetric, isPeriodBestValue } from '../../../helpers/measures'; import { BranchLike } from '../../../types/branch-like'; import { MeasurePageView } from '../../../types/measures'; -import { - ComponentMeasure, - ComponentMeasureEnhanced, - Dict, - Metric, - Paging, -} from '../../../types/types'; +import { Component, ComponentMeasureEnhanced, Dict, Metric, Paging } from '../../../types/types'; import ComponentsList from './ComponentsList'; interface Props { @@ -50,7 +44,7 @@ interface Props { metric: Metric; metrics: Dict; paging?: Paging; - rootComponent: ComponentMeasure; + rootComponent: Component; selectedComponent?: ComponentMeasureEnhanced; selectedIdx?: number; view: MeasurePageView; diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/MeasureCell.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/MeasureCell.tsx index 61fbe5405a5..a3de418f7f8 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/MeasureCell.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/MeasureCell.tsx @@ -21,15 +21,17 @@ import { NumericalCell } from 'design-system'; import * as React from 'react'; import Measure from '~sonar-aligned/components/measure/Measure'; import { getCCTMeasureValue, isDiffMetric } from '../../../helpers/measures'; +import { BranchLike } from '../../../types/branch-like'; import { ComponentMeasureEnhanced, MeasureEnhanced, Metric } from '../../../types/types'; interface Props { + branchLike?: BranchLike; component: ComponentMeasureEnhanced; measure?: MeasureEnhanced; metric: Metric; } -export default function MeasureCell({ component, measure, metric }: Readonly) { +export default function MeasureCell({ component, measure, metric, branchLike }: Readonly) { const getValue = (item: { leak?: string; value?: string }) => isDiffMetric(metric.key) ? item.leak : item.value; @@ -39,6 +41,7 @@ export default function MeasureCell({ component, measure, metric }: Readonly void; + isLegacyMode: boolean; metric: Metric; } @@ -56,7 +57,8 @@ interface State { } const PERCENT_SCALE_DOMAIN = [0, 25, 50, 75, 100]; -const RATING_SCALE_DOMAIN = [1, 2, 3, 4, 5]; +const RATING_SCALE_DOMAIN = [1, 2, 3, 4]; +const LEGACY_RATING_SCALE_DOMAIN = [1, 2, 3, 4, 5]; const HEIGHT = 500; const NA_COLORS: [ThemeColors, ThemeColors] = ['treeMap.NA1', 'treeMap.NA2']; @@ -67,6 +69,13 @@ const TREEMAP_COLORS: ThemeColors[] = [ 'treeMap.D', 'treeMap.E', ]; +const TREEMAP_LEGACY_COLORS: ThemeColors[] = [ + 'treeMap.legacy.A', + 'treeMap.legacy.B', + 'treeMap.legacy.C', + 'treeMap.legacy.D', + 'treeMap.legacy.E', +]; export class TreeMapView extends React.PureComponent { state: State; @@ -140,8 +149,10 @@ export class TreeMapView extends React.PureComponent { }; getMappedThemeColors = (): string[] => { - const { theme } = this.props; - return TREEMAP_COLORS.map((c) => themeColor(c)({ theme })); + const { theme, isLegacyMode } = this.props; + return (isLegacyMode ? TREEMAP_LEGACY_COLORS : TREEMAP_COLORS).map((c) => + themeColor(c)({ theme }), + ); }; getLevelColorScale = () => @@ -159,8 +170,12 @@ export class TreeMapView extends React.PureComponent { return color; }; - getRatingColorScale = () => - scaleLinear().domain(RATING_SCALE_DOMAIN).range(this.getMappedThemeColors()); + getRatingColorScale = () => { + const { isLegacyMode } = this.props; + return scaleLinear() + .domain(isLegacyMode ? LEGACY_RATING_SCALE_DOMAIN : RATING_SCALE_DOMAIN) + .range(this.getMappedThemeColors()); + }; getColorScale = (metric: Metric) => { if (metric.type === MetricType.Level) { diff --git a/server/sonar-web/src/main/js/apps/component-measures/hooks.ts b/server/sonar-web/src/main/js/apps/component-measures/hooks.ts new file mode 100644 index 00000000000..6cf2f3042bb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/hooks.ts @@ -0,0 +1,42 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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 { areCCTMeasuresComputed, areSoftwareQualityRatingsComputed } from '../../helpers/measures'; +import { useIsLegacyCCTMode } from '../../queries/settings'; +import { MeasureEnhanced } from '../../types/types'; +import { + legacyBubbles, + newTaxonomyBubbles, + newTaxonomyWithoutRatingsBubbles, +} from './config/bubbles'; + +export function useBubbleChartMetrics(measures: MeasureEnhanced[]) { + const { data: isLegacyFlag } = useIsLegacyCCTMode(); + + if (isLegacyFlag || !areCCTMeasuresComputed(measures)) { + return legacyBubbles; + } + + if (!areSoftwareQualityRatingsComputed(measures)) { + return newTaxonomyWithoutRatingsBubbles; + } + + return newTaxonomyBubbles; +} 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 a91187072a0..c7a46d59b37 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 @@ -33,6 +33,7 @@ import { translate, } from '../../../helpers/l10n'; import { MeasureEnhanced } from '../../../types/types'; +import { useBubbleChartMetrics } from '../hooks'; import { addMeasureCategories, getMetricSubnavigationName, @@ -44,6 +45,7 @@ import DomainSubnavigationItem from './DomainSubnavigationItem'; interface Props { componentKey: string; domain: { measures: MeasureEnhanced[]; name: string }; + measures: MeasureEnhanced[]; onChange: (metric: string) => void; open: boolean; selected: string; @@ -51,16 +53,17 @@ interface Props { } export default function DomainSubnavigation(props: Readonly) { - const { componentKey, domain, onChange, open, selected, showFullMeasures } = props; + const { componentKey, domain, onChange, open, selected, showFullMeasures, measures } = props; const helperMessageKey = `component_measures.domain_subnavigation.${domain.name}.help`; const helper = hasMessage(helperMessageKey) ? translate(helperMessageKey) : undefined; const items = addMeasureCategories(domain.name, domain.measures); + const bubbles = useBubbleChartMetrics(measures); const hasCategories = items.some((item) => typeof item === 'string'); const translateMetric = hasCategories ? getLocalizedCategoryMetricName : getLocalizedMetricName; let sortedItems = sortMeasures(domain.name, items); const hasOverview = (domain: string) => { - return showFullMeasures && hasBubbleChart(domain); + return showFullMeasures && hasBubbleChart(bubbles, domain); }; // sortedItems contains both measures (type object) and categories (type string) diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx index 9a3cf5fb6b8..d3df6b22d57 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx @@ -104,6 +104,7 @@ export default function Sidebar(props: Readonly) { domain={domain} key={domain.name} onChange={handleChangeMetric} + measures={measures} open={isDomainSelected(selectedMetric, domain)} selected={selectedMetric} showFullMeasures={showFullMeasures} diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx index 3ac980e5bea..d6c55e29188 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx @@ -21,6 +21,7 @@ import { Note } from 'design-system'; import React from 'react'; import Measure from '~sonar-aligned/components/measure/Measure'; import { isDiffMetric } from '../../../helpers/measures'; +import { useBranchesQuery } from '../../../queries/branch'; import { MeasureEnhanced } from '../../../types/types'; interface Props { @@ -30,6 +31,7 @@ interface Props { export default function SubnavigationMeasureValue({ measure, componentKey }: Readonly) { const isDiff = isDiffMetric(measure.metric.key); + const { data: { branchLike } = {} } = useBranchesQuery(); const value = isDiff ? measure.leak : measure.value; return ( @@ -38,6 +40,7 @@ export default function SubnavigationMeasureValue({ measure, componentKey }: Rea id={`measure-${measure.metric.key}-${isDiff ? 'leak' : 'value'}`} > !LEAK_OLD_TAXONOMY_METRICS.includes(measure.metric.key as MetricKey), ); } + + // Both new and overall code will exist after next analysis + if (areSoftwareQualityRatingsComputed(measures)) { + populatedMeasures = populatedMeasures.filter( + (measure) => + !OLD_TAXONOMY_RATINGS.includes(measure.metric.key as MetricKey) && + !LEAK_OLD_TAXONOMY_RATINGS.includes(measure.metric.key as MetricKey), + ); + } + if (areCCTMeasuresComputed(measures)) { populatedMeasures = populatedMeasures.filter( (measure) => !OLD_TAXONOMY_METRICS.includes(measure.metric.key as MetricKey), @@ -249,8 +263,8 @@ export function hasTreemap(metric: string, type: string): boolean { ); } -export function hasBubbleChart(domainName: string): boolean { - return bubbles[domainName] !== undefined; +export function hasBubbleChart(bubblesByDomain: BubblesByDomain, domainName: string): boolean { + return bubblesByDomain[domainName] !== undefined; } export function hasFacetStat(metric: string): boolean { @@ -262,7 +276,11 @@ export function hasFullMeasures(branch?: BranchLike) { } export function getMeasuresPageMetricKeys(metrics: Dict, branch?: BranchLike) { - const metricKeys = getDisplayMetrics(Object.values(metrics)).map((metric) => metric.key); + // ToDo rollback once new metrics are available + const metricKeys = [ + ...getDisplayMetrics(Object.values(metrics)).map((metric) => metric.key), + ...SOFTWARE_QUALITY_RATING_METRICS, + ]; if (isPullRequest(branch)) { return metricKeys.filter((key) => isDiffMetric(key)); @@ -271,8 +289,12 @@ export function getMeasuresPageMetricKeys(metrics: Dict, branch?: Branch return metricKeys; } -export function getBubbleMetrics(domain: string, metrics: Dict) { - const conf = bubbles[domain]; +export function getBubbleMetrics( + bubblesByDomain: BubblesByDomain, + domain: string, + metrics: Dict, +) { + const conf = bubblesByDomain[domain]; return { x: metrics[conf.x], y: metrics[conf.y], @@ -281,8 +303,8 @@ export function getBubbleMetrics(domain: string, metrics: Dict) { }; } -export function getBubbleYDomain(domain: string) { - return bubbles[domain].yDomain; +export function getBubbleYDomain(bubblesByDomain: BubblesByDomain, domain: string) { + return bubblesByDomain[domain].yDomain; } export function isProjectOverview(metric: string) { diff --git a/server/sonar-web/src/main/js/apps/overview/branches/NewCodeMeasuresPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/NewCodeMeasuresPanel.tsx index cd576b12c52..e56bf23087c 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/NewCodeMeasuresPanel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/NewCodeMeasuresPanel.tsx @@ -292,6 +292,7 @@ export default function NewCodeMeasuresPanel(props: Readonly) { icon={ newSecurityReviewRating ? ( { }; render() { - const { condition, component } = this.props; + const { condition, component, branchLike } = this.props; const { measure } = condition; const { metric } = measure; @@ -157,6 +157,7 @@ export class QualityGateCondition extends React.PureComponent { return this.wrapWithLink(
) { - const { ratingMetricKey, componentKey, softwareQuality } = props; + const { ratingMetricKey, componentKey, softwareQuality, branch } = props; const intl = useIntl(); @@ -101,6 +103,7 @@ export function SoftwareImpactMeasureRating(props: Readonly>) { component.key, ); - const { data: componentMeasures, isLoading: isLoadingMeasures } = - useComponentMeasuresWithMetricsQuery( - component.key, - uniq([...PR_METRICS, ...(conditions?.map((c) => c.metric) ?? [])]), - getBranchLikeQuery(pullRequest), - !isLoadingBranchStatusesData, - ); + const { data: componentMeasures, isLoading: isLoadingMeasures } = useMeasuresComponentQuery( + { + componentKey: component.key, + metricKeys: uniq([...PR_METRICS, ...(conditions?.map((c) => c.metric) ?? [])]), + branchLike: pullRequest, + }, + { enabled: !isLoadingBranchStatusesData }, + ); const measures = componentMeasures ? enhanceMeasuresWithMetrics( diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx index ef2941c81d3..81761c5c5dd 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx @@ -83,6 +83,7 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) { {component && ( = { + [MetricKey.sqale_rating]: MetricKey.sqale_rating_new, + [MetricKey.security_rating]: MetricKey.security_rating_new, + [MetricKey.reliability_rating]: MetricKey.reliability_rating_new, + [MetricKey.security_review_rating]: MetricKey.security_review_rating_new, + [MetricKey.releasability_rating]: MetricKey.releasability_rating_new, + [MetricKey.new_maintainability_rating]: MetricKey.new_maintainability_rating_new, + [MetricKey.new_security_rating]: MetricKey.new_security_rating_new, + [MetricKey.new_reliability_rating]: MetricKey.new_reliability_rating_new, + [MetricKey.new_security_review_rating]: MetricKey.new_security_review_rating_new, +}; + +export const SOFTWARE_QUALITY_RATING_METRICS = [ + MetricKey.sqale_rating_new, + MetricKey.security_rating_new, + MetricKey.reliability_rating_new, + MetricKey.security_review_rating_new, + MetricKey.releasability_rating_new, + MetricKey.new_maintainability_rating_new, + MetricKey.new_security_rating_new, + MetricKey.new_reliability_rating_new, + MetricKey.new_security_review_rating_new, +]; + export const PROJECT_KEY_MAX_LEN = 400; export const IMPORT_COMPATIBLE_ALMS = [ diff --git a/server/sonar-web/src/main/js/helpers/measures.ts b/server/sonar-web/src/main/js/helpers/measures.ts index 4a3e774ded7..6b0d5cb0687 100644 --- a/server/sonar-web/src/main/js/helpers/measures.ts +++ b/server/sonar-web/src/main/js/helpers/measures.ts @@ -27,6 +27,7 @@ import { CCT_SOFTWARE_QUALITY_METRICS, LEAK_CCT_SOFTWARE_QUALITY_METRICS, LEAK_OLD_TAXONOMY_METRICS, + SOFTWARE_QUALITY_RATING_METRICS, } from './constants'; import { translate } from './l10n'; import { isDefined } from './types'; @@ -114,6 +115,13 @@ export function areCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[]) ), ); } +export function areSoftwareQualityRatingsComputed(measures?: Measure[] | MeasureEnhanced[]) { + return SOFTWARE_QUALITY_RATING_METRICS.every((metric) => + measures?.find((measure) => + isMeasureEnhanced(measure) ? measure.metric.key === metric : measure.metric === metric, + ), + ); +} export function areLeakAndOverallCCTMeasuresComputed(measures?: Measure[] | MeasureEnhanced[]) { return areLeakCCTMeasuresComputed(measures) && areCCTMeasuresComputed(measures); diff --git a/server/sonar-web/src/main/js/queries/component.ts b/server/sonar-web/src/main/js/queries/component.ts index b3d6445a870..31596b4fcd8 100644 --- a/server/sonar-web/src/main/js/queries/component.ts +++ b/server/sonar-web/src/main/js/queries/component.ts @@ -18,7 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { - UseQueryResult, infiniteQueryOptions, queryOptions, useQuery, @@ -33,10 +32,8 @@ import { getComponentData, getComponentTree, } from '../api/components'; -import { getMeasuresWithMetrics } from '../api/measures'; import { getNextPageParam, getPreviousPageParam } from '../helpers/react-query'; import { MetricKey } from '../sonar-aligned/types/metrics'; -import { MeasuresAndMetaWithMetrics } from '../types/measures'; import { Component, Measure } from '../types/types'; import { StaleTime, createInfiniteQueryHook, createQueryHook } from './common'; @@ -75,37 +72,6 @@ export function useTaskForComponentQuery(component: Component) { }); } -export function useComponentMeasuresWithMetricsQuery( - key: string, - metricKeys: string[], - branchParameters: BranchParameters, - enabled = true, -): UseQueryResult { - return useQuery({ - enabled, - queryKey: [ - 'component', - key, - 'measures', - 'with_metrics', - { - metricKeys, - branchParameters, - }, - ] as const, - queryFn: ({ queryKey: [, key, , , data] }) => { - return ( - data && - getMeasuresWithMetrics( - key, - data.metricKeys.filter((m) => !NEW_METRICS.includes(m as MetricKey)), - data.branchParameters, - ) - ); - }, - }); -} - export const useComponentQuery = createQueryHook( ({ component, metricKeys, ...params }: Parameters[0]) => { const queryClient = useQueryClient(); diff --git a/server/sonar-web/src/main/js/queries/measures.ts b/server/sonar-web/src/main/js/queries/measures.ts index d574084311f..b7274ac555e 100644 --- a/server/sonar-web/src/main/js/queries/measures.ts +++ b/server/sonar-web/src/main/js/queries/measures.ts @@ -18,18 +18,28 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; -import { groupBy } from 'lodash'; +import { + infiniteQueryOptions, + queryOptions, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { groupBy, isUndefined, omitBy } from 'lodash'; import { BranchParameters } from '~sonar-aligned/types/branch-like'; +import { getComponentTree } from '../api/components'; import { getMeasures, getMeasuresForProjects, getMeasuresWithPeriodAndMetrics, } from '../api/measures'; import { getAllTimeMachineData } from '../api/time-machine'; +import { SOFTWARE_QUALITY_RATING_METRICS } from '../helpers/constants'; +import { getNextPageParam, getPreviousPageParam } from '../helpers/react-query'; +import { getBranchLikeQuery } from '../sonar-aligned/helpers/branch-like'; import { MetricKey } from '../sonar-aligned/types/metrics'; +import { BranchLike } from '../types/branch-like'; import { Measure } from '../types/types'; -import { createQueryHook } from './common'; +import {createInfiniteQueryHook, createQueryHook} from './common'; const NEW_METRICS = [ MetricKey.software_quality_maintainability_rating, @@ -63,15 +73,125 @@ export function useAllMeasuresHistoryQuery( }); } +export const useMeasuresComponentQuery = createQueryHook( + ({ + componentKey, + metricKeys, + branchLike, + }: { + branchLike?: BranchLike; + componentKey: string; + metricKeys: string[]; + }) => { + const queryClient = useQueryClient(); + const branchLikeQuery = getBranchLikeQuery(branchLike); + + return queryOptions({ + queryKey: ['measures', 'component', componentKey, 'branchLike', branchLikeQuery, metricKeys], + queryFn: async () => { + const data = await getMeasuresWithPeriodAndMetrics( + componentKey, + metricKeys.filter((m) => !SOFTWARE_QUALITY_RATING_METRICS.includes(m as MetricKey)), + branchLikeQuery, + ); + metricKeys.forEach((metricKey) => { + const measure = + data.component.measures?.find((measure) => measure.metric === metricKey) ?? null; + queryClient.setQueryData( + ['measures', 'details', componentKey, 'branchLike', branchLikeQuery, metricKey], + measure, + ); + }); + + return data; + }, + }); + }, +); + +export const useComponentTreeQuery = createInfiniteQueryHook( + ({ + strategy, + component, + metrics, + additionalData, + }: { + additionalData: Parameters[3]; + component: Parameters[1]; + metrics: Parameters[2]; + strategy: 'children' | 'leaves'; + }) => { + const branchLikeQuery = omitBy( + { + branch: additionalData?.branch, + pullRequest: additionalData?.pullRequest, + }, + isUndefined, + ); + + const queryClient = useQueryClient(); + return infiniteQueryOptions({ + queryKey: ['component', component, 'tree', strategy, { metrics, additionalData }], + queryFn: async ({ pageParam }) => { + const result = await getComponentTree( + strategy, + component, + metrics?.filter((m) => !SOFTWARE_QUALITY_RATING_METRICS.includes(m as MetricKey)), + { ...additionalData, p: pageParam, ...branchLikeQuery }, + ); + + // const measuresMapByMetricKeyForBaseComponent = groupBy( + // result.baseComponent.measures, + // 'metric', + // ); + // metrics?.forEach((metricKey) => { + // const measure = measuresMapByMetricKeyForBaseComponent[metricKey]?.[0] ?? null; + // queryClient.setQueryData( + // [ + // 'measures', + // 'details', + // result.baseComponent.key, + // 'branchLike', + // branchLikeQuery, + // metricKey, + // ], + // measure, + // ); + // }); + result.components.forEach((childComponent) => { + const measuresMapByMetricKeyForChildComponent = groupBy( + childComponent.measures, + 'metric', + ); + + metrics?.forEach((metricKey) => { + const measure = measuresMapByMetricKeyForChildComponent[metricKey]?.[0] ?? null; + queryClient.setQueryData( + ['measures', 'details', childComponent.key, 'branchLike', branchLikeQuery, metricKey], + measure, + ); + }); + }); + return result; + }, + getNextPageParam: (data) => getNextPageParam({ page: data.paging }), + getPreviousPageParam: (data) => getPreviousPageParam({ page: data.paging }), + initialPageParam: 1, + staleTime: 60_000, + }); + }, +); + export const useMeasuresForProjectsQuery = createQueryHook( ({ projectKeys, metricKeys }: { metricKeys: string[]; projectKeys: string[] }) => { const queryClient = useQueryClient(); + return queryOptions({ queryKey: ['measures', 'list', 'projects', projectKeys, metricKeys], queryFn: async () => { // TODO remove this once all metrics are supported const filteredMetricKeys = metricKeys.filter( - (metricKey) => !NEW_METRICS.includes(metricKey as MetricKey), + (metricKey) => !SOFTWARE_QUALITY_RATING_METRICS.includes(metricKey as MetricKey), ); const measures = await getMeasuresForProjects(projectKeys, filteredMetricKeys); const measuresMapByProjectKey = groupBy(measures, 'component'); @@ -81,7 +201,7 @@ export const useMeasuresForProjectsQuery = createQueryHook( metricKeys.forEach((metricKey) => { const measure = measuresMapByMetricKey[metricKey]?.[0] ?? null; queryClient.setQueryData( - ['measures', 'details', projectKey, metricKey], + ['measures', 'details', projectKey, 'branchLike', {}, metricKey], measure, ); }); @@ -130,9 +250,19 @@ export const useMeasuresAndLeakQuery = createQueryHook( ); export const useMeasureQuery = createQueryHook( - ({ componentKey, metricKey }: { componentKey: string; metricKey: string }) => { + ({ + componentKey, + metricKey, + branchLike, + }: { + branchLike?: BranchLike; + componentKey: string; + metricKey: string; + }) => { + const branchLikeQuery = getBranchLikeQuery(branchLike); + return queryOptions({ - queryKey: ['measures', 'details', componentKey, metricKey], + queryKey: ['measures', 'details', componentKey, 'branchLike', branchLikeQuery, metricKey], queryFn: () => getMeasures({ component: componentKey, metricKeys: metricKey }).then( (measures) => measures[0] ?? null, diff --git a/server/sonar-web/src/main/js/sonar-aligned/components/measure/Measure.tsx b/server/sonar-web/src/main/js/sonar-aligned/components/measure/Measure.tsx index 1b57b71cb64..c9381dc62d4 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/components/measure/Measure.tsx +++ b/server/sonar-web/src/main/js/sonar-aligned/components/measure/Measure.tsx @@ -26,9 +26,11 @@ import { Status } from '~sonar-aligned/types/common'; import { MetricKey, MetricType } from '~sonar-aligned/types/metrics'; import RatingComponent from '../../../app/components/metrics/RatingComponent'; import RatingTooltipContent from '../../../components/measure/RatingTooltipContent'; +import { BranchLike } from '../../../types/branch-like'; interface Props { badgeSize?: 'xs' | 'sm' | 'md'; + branchLike?: BranchLike; className?: string; componentKey: string; decimals?: number; @@ -46,6 +48,7 @@ export default function Measure({ decimals, fontClassName, metricKey, + branchLike, metricType, small, value, @@ -106,6 +109,7 @@ export default function Measure({ const rating = (