From 8073032868b6763891a12f4a4b4d4cb2507a179d Mon Sep 17 00:00:00 2001 From: Ismail Cherri Date: Tue, 15 Oct 2024 15:48:51 +0200 Subject: [PATCH] SONAR-23191 Quality Profile details adopts MQR mode --- .../src/main/js/api/quality-profiles.ts | 5 +- .../__tests__/QualityProfileApp-it.tsx | 28 +- .../quality-profiles/details/ProfileRules.tsx | 347 ++++++++++-------- .../details/ProfileRulesRow.tsx | 36 +- .../src/main/js/queries/quality-profiles.ts | 21 +- server/sonar-web/src/main/js/queries/rules.ts | 12 +- server/sonar-web/src/main/js/types/types.ts | 9 +- 7 files changed, 273 insertions(+), 185 deletions(-) diff --git a/server/sonar-web/src/main/js/api/quality-profiles.ts b/server/sonar-web/src/main/js/api/quality-profiles.ts index 4ec559294c4..d2b2898e330 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -81,7 +81,10 @@ export function getQualityProfile({ }: { compareToSonarWay?: boolean; profile: Profile; -}): Promise { +}): Promise<{ + compareToSonarWay?: { missingRuleCount: number; profile: string; profileName: string }; + profile: Profile; +}> { return getJSON('/api/qualityprofiles/show', { compareToSonarWay, key }); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx index 18bf2780237..f4292e8e7f6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx @@ -21,7 +21,9 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { byRole, byText } from '~sonar-aligned/helpers/testSelector'; import QualityProfilesServiceMock from '../../../api/mocks/QualityProfilesServiceMock'; +import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; +import { SettingsKey } from '../../../types/settings'; import routes from '../routes'; jest.mock('../../../api/quality-profiles'); @@ -29,14 +31,17 @@ jest.mock('../../../api/rules'); beforeEach(() => { serviceMock.reset(); + settingsMock.reset(); }); const serviceMock = new QualityProfilesServiceMock(); +const settingsMock = new SettingsServiceMock(); const ui = { loading: byRole('status', { name: 'loading' }), permissionSection: byRole('region', { name: 'permissions.page' }), projectSection: byRole('region', { name: 'projects' }), rulesSection: byRole('region', { name: 'rules' }), + rulesSectionHeader: byRole('heading', { name: 'quality_profile.rules.breakdown' }), exportersSection: byRole('region', { name: 'quality_profiles.exporters' }), inheritanceSection: byRole('region', { name: 'quality_profiles.profile_inheritance' }), grantPermissionButton: byRole('button', { @@ -59,8 +64,8 @@ const ui = { }), qualityProfilesHeader: byRole('heading', { name: 'quality_profiles.page' }), deleteQualityProfileButton: byRole('menuitem', { name: 'delete' }), - activateMoreRulesButton: byRole('button', { name: 'quality_profiles.activate_more' }), activateMoreLink: byRole('link', { name: 'quality_profiles.activate_more' }), + activateMoreButton: byRole('button', { name: 'quality_profiles.activate_more' }), activateMoreRulesLink: byRole('menuitem', { name: 'quality_profiles.activate_more_rules' }), backUpLink: byRole('menuitem', { name: 'backup_verb open_in_new_tab' }), compareLink: byRole('menuitem', { name: 'compare' }), @@ -224,7 +229,7 @@ describe('Admin or user with permission', () => { expect(await ui.rulesSection.find()).toBeInTheDocument(); - expect(ui.activateMoreLink.get()).toBeInTheDocument(); + expect(await ui.activateMoreLink.find()).toBeInTheDocument(); expect(ui.activateMoreLink.get()).toHaveAttribute( 'href', '/coding_rules?qprofile=old-php-qp&activation=false', @@ -235,8 +240,8 @@ describe('Admin or user with permission', () => { renderQualityProfile('sonar'); await ui.waitForDataLoaded(); expect(await ui.rulesSection.find()).toBeInTheDocument(); - expect(ui.activateMoreRulesButton.get()).toBeInTheDocument(); - expect(ui.activateMoreRulesButton.get()).toBeDisabled(); + expect(await ui.activateMoreButton.find()).toBeInTheDocument(); + expect(ui.activateMoreButton.get()).toBeDisabled(); }); }); @@ -450,7 +455,7 @@ describe('Every Users', () => { renderQualityProfile(); await ui.waitForDataLoaded(); - expect(await ui.rulesSection.find()).toBeInTheDocument(); + expect(await ui.rulesSectionHeader.find()).toBeInTheDocument(); ui.checkRuleRow('rule.clean_code_attribute_category.INTENTIONAL', 23, 4); ui.checkRuleRow('rule.clean_code_attribute_category.CONSISTENT', 2, 18); @@ -461,6 +466,19 @@ describe('Every Users', () => { ui.checkRuleRow('software_quality.SECURITY', 0, 14); }); + it('should be able to see active/inactive rules for a Quality Profile in Legacy mode', async () => { + settingsMock.set(SettingsKey.MQRMode, 'false'); + renderQualityProfile(); + await ui.waitForDataLoaded(); + + expect(await ui.rulesSectionHeader.find()).toBeInTheDocument(); + + ui.checkRuleRow('issue.type.BUG.plural', 60, 0); + ui.checkRuleRow('issue.type.VULNERABILITY.plural', 40, 0); + ui.checkRuleRow('issue.type.CODE_SMELL.plural', 250, 0); + ui.checkRuleRow('issue.type.SECURITY_HOTSPOT.plural', 50, 0); + }); + it('should be able to see a warning when some rules are missing compare to Sonar way', async () => { renderQualityProfile(); await ui.waitForDataLoaded(); diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRules.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRules.tsx index b23f72ebb9c..eebcabef175 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRules.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRules.tsx @@ -18,29 +18,28 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import styled from '@emotion/styled'; +import { Heading, Spinner } from '@sonarsource/echoes-react'; import { ButtonPrimary, ContentCell, NumericalCell, - Spinner, - SubTitle, Table, TableRow, themeColor, } from 'design-system'; import { keyBy } from 'lodash'; import * as React from 'react'; -import { useEffect, useState } from 'react'; import DocHelpTooltip from '~sonar-aligned/components/controls/DocHelpTooltip'; -import { getQualityProfile } from '../../../api/quality-profiles'; -import { searchRules } from '../../../api/rules'; import { translate } from '../../../helpers/l10n'; import { isDefined } from '../../../helpers/types'; import { getRulesUrl } from '../../../helpers/urls'; +import { useGetQualityProfile } from '../../../queries/quality-profiles'; +import { useSearchRulesQuery } from '../../../queries/rules'; +import { useIsLegacyCCTMode } from '../../../queries/settings'; import { CleanCodeAttributeCategory, SoftwareQuality } from '../../../types/clean-code-taxonomy'; import { SearchRulesResponse } from '../../../types/coding-rules'; import { RulesFacetName } from '../../../types/rules'; -import { Dict } from '../../../types/types'; +import { RuleTypes } from '../../../types/types'; import { Profile } from '../types'; import ProfileRulesDeprecatedWarning from './ProfileRulesDeprecatedWarning'; import ProfileRulesRow from './ProfileRulesRow'; @@ -56,176 +55,206 @@ interface ByType { } export default function ProfileRules({ profile }: Readonly) { + const { data: isLegacy } = useIsLegacyCCTMode(); const activateMoreUrl = getRulesUrl({ qprofile: profile.key, activation: 'false' }); const { actions = {} } = profile; - const [loading, setLoading] = useState(false); - const [countsByCctCategory, setCountsByCctCategory] = useState>({}); - const [totalByCctCategory, setTotalByCctCategory] = useState>({}); - const [countsBySoftwareImpact, setCountsBySoftwareImpact] = useState>({}); - const [totalBySoftwareQuality, setTotalBySoftwareQuality] = useState>({}); - const [sonarWayDiff, setSonarWayDiff] = useState<{ - missingRuleCount: number; - profile: string; - profileName: string; - } | null>(null); - - useEffect(() => { - async function loadRules() { - function findFacet(response: SearchRulesResponse, property: string) { - const facet = response.facets?.find((f) => f.property === property); - return facet ? facet.values : []; - } + const { data: allRules, isLoading: isAllRulesLoading } = useSearchRulesQuery({ + ps: 1, + languages: profile.language, + facets: isLegacy + ? `${RulesFacetName.Types}` + : `${RulesFacetName.CleanCodeAttributeCategories},${RulesFacetName.ImpactSoftwareQualities}`, + }); + + const { data: activatedRules, isLoading: isActivatedRulesLoading } = useSearchRulesQuery( + { + ps: 1, + activation: 'true', + facets: isLegacy + ? `${RulesFacetName.Types}` + : `${RulesFacetName.CleanCodeAttributeCategories},${RulesFacetName.ImpactSoftwareQualities}`, + qprofile: profile.key, + }, + { enabled: !!allRules }, + ); + + const { data: sonarWayDiff, isLoading: isShowProfileLoading } = useGetQualityProfile( + { compareToSonarWay: true, profile }, + { enabled: !profile.isBuiltIn, select: (data) => data.compareToSonarWay }, + ); + + const findFacet = React.useCallback((response: SearchRulesResponse, property: string) => { + const facet = response.facets?.find((f) => f.property === property); + return facet ? facet.values : []; + }, []); - try { - setLoading(true); - return await Promise.all([ - searchRules({ - languages: profile.language, - facets: `${RulesFacetName.CleanCodeAttributeCategories},${RulesFacetName.ImpactSoftwareQualities}`, - }), - searchRules({ - activation: 'true', - facets: `${RulesFacetName.CleanCodeAttributeCategories},${RulesFacetName.ImpactSoftwareQualities}`, - qprofile: profile.key, - }), - !profile.isBuiltIn && - getQualityProfile({ - compareToSonarWay: true, - profile, - }), - ]).then((responses) => { - const [allRules, activatedRules, showProfile] = responses; - const extractFacetData = (facetName: string, response: SearchRulesResponse) => { - return keyBy(findFacet(response, facetName), 'val'); - }; - setTotalByCctCategory( - extractFacetData(RulesFacetName.CleanCodeAttributeCategories, allRules), - ); - setCountsByCctCategory( - extractFacetData(RulesFacetName.CleanCodeAttributeCategories, activatedRules), - ); - setTotalBySoftwareQuality( - extractFacetData(RulesFacetName.ImpactSoftwareQualities, allRules), - ); - setCountsBySoftwareImpact( - extractFacetData(RulesFacetName.ImpactSoftwareQualities, activatedRules), - ); - setSonarWayDiff(showProfile?.compareToSonarWay); - }); - } finally { - setLoading(false); + const extractFacetData = React.useCallback( + (facetName: string, response: SearchRulesResponse | null | undefined) => { + if (!response) { + return {}; } - } - loadRules(); - }, [profile]); + return keyBy(findFacet(response, facetName), 'val'); + }, + [findFacet], + ); - if (loading) { - return ; - } + const totalByCctCategory = extractFacetData( + RulesFacetName.CleanCodeAttributeCategories, + allRules, + ); + const countsByCctCategory = extractFacetData( + RulesFacetName.CleanCodeAttributeCategories, + activatedRules, + ); + const totalBySoftwareQuality = extractFacetData(RulesFacetName.ImpactSoftwareQualities, allRules); + const countsBySoftwareImpact = extractFacetData( + RulesFacetName.ImpactSoftwareQualities, + activatedRules, + ); + const totalByTypes = extractFacetData(RulesFacetName.Types, allRules); + const countsByTypes = extractFacetData(RulesFacetName.Types, activatedRules); return (
- {translate('quality_profile.rules.breakdown')} - - - - {translate('quality_profile.rules.cct_categories_title')} - - {translate('active')} - - {translate('inactive')} - - - } - noHeaderTopBorder - noSidePadding - withRoundedBorder - > - {Object.values(CleanCodeAttributeCategory).map((category) => ( - - ))} -
- - - - {translate('quality_profile.rules.software_qualities_title')} - - {translate('active')} - - {translate('inactive')} - - - } - noHeaderTopBorder - noSidePadding - withRoundedBorder - > - {Object.values(SoftwareQuality).map((quality) => ( - - ))} -
- -
- {profile.activeDeprecatedRuleCount > 0 && ( - - )} + + + {translate('quality_profile.rules.breakdown')} + - {isDefined(sonarWayDiff) && sonarWayDiff.missingRuleCount > 0 && ( - + {isLegacy && ( + + {translate('type')} + {translate('active')} + + {translate('inactive')} + + + } + noHeaderTopBorder + noSidePadding + withRoundedBorder + > + {RuleTypes.filter((type) => type !== 'UNKNOWN').map((type) => ( + + ))} +
)} - {actions.edit && !profile.isBuiltIn && ( - - {translate('quality_profiles.activate_more')} - + {!isLegacy && ( + <> + + + {translate('quality_profile.rules.software_qualities_title')} + + {translate('active')} + + {translate('inactive')} + + + } + noHeaderTopBorder + noSidePadding + withRoundedBorder + > + {Object.values(SoftwareQuality).map((quality) => ( + + ))} +
+ + + + {translate('quality_profile.rules.cct_categories_title')} + + {translate('active')} + + {translate('inactive')} + + + } + noHeaderTopBorder + noSidePadding + withRoundedBorder + > + {Object.values(CleanCodeAttributeCategory).map((category) => ( + + ))} +
+ )} - {/* if a user is allowed to `copy` a profile if they are a global admin */} - {/* this user could potentially activate more rules if the profile was not built-in */} - {/* in such cases it's better to show the button but disable it with a tooltip */} - {actions.copy && profile.isBuiltIn && ( - - +
+ {profile.activeDeprecatedRuleCount > 0 && ( + + )} + + {isDefined(sonarWayDiff) && sonarWayDiff.missingRuleCount > 0 && ( + + )} + + {actions.edit && !profile.isBuiltIn && ( + {translate('quality_profiles.activate_more')} - - )} -
+ )} + + {/* if a user is allowed to `copy` a profile if they are a global admin */} + {/* this user could potentially activate more rules if the profile was not built-in */} + {/* in such cases it's better to show the button but disable it with a tooltip */} + {actions.copy && profile.isBuiltIn && ( + + + {translate('quality_profiles.activate_more')} + + + )} +
+
); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRulesRow.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRulesRow.tsx index 698eb5e917f..8cd7641e64c 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRulesRow.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRulesRow.tsx @@ -25,49 +25,57 @@ import { translateWithParameters } from '../../../helpers/l10n'; import { isDefined } from '../../../helpers/types'; import { getRulesUrl } from '../../../helpers/urls'; import { RulesFacetName } from '../../../types/rules'; +import { RuleType } from '../../../types/types'; interface Props { className?: string; count: number | null; - propertyName: + propertyName?: | RulesFacetName.CleanCodeAttributeCategories | RulesFacetName.ImpactSoftwareQualities; - propertyValue: string; + propertyValue?: string; qprofile: string; title: string; total: number | null; + type?: RuleType; } export default function ProfileRulesRow(props: Readonly) { + const { qprofile, count, className, propertyName, propertyValue, title, total, type } = props; + + const typeOrCCTQuery = { + ...(propertyName ? { [propertyName]: propertyValue } : {}), + ...(type ? { types: type } : {}), + }; const activeRulesUrl = getRulesUrl({ - qprofile: props.qprofile, + qprofile, activation: 'true', - [props.propertyName]: props.propertyValue, + ...typeOrCCTQuery, }); const inactiveRulesUrl = getRulesUrl({ - qprofile: props.qprofile, + qprofile, activation: 'false', - [props.propertyName]: props.propertyValue, + ...typeOrCCTQuery, }); let inactiveCount = null; - if (props.count != null && props.total != null) { - inactiveCount = props.total - props.count; + if (count != null && total != null) { + inactiveCount = total - count; } return ( - - {props.title} + + {title} - {isDefined(props.count) && props.count > 0 ? ( + {isDefined(count) && count > 0 ? ( - {formatMeasure(props.count, MetricType.ShortInteger)} + {formatMeasure(count, MetricType.ShortInteger)} ) : ( 0 @@ -79,7 +87,7 @@ export default function ProfileRulesRow(props: Readonly) { aria-label={translateWithParameters( 'quality_profile.rules.see_x_inactive_x_rules', inactiveCount, - props.title, + title, )} to={inactiveRulesUrl} > diff --git a/server/sonar-web/src/main/js/queries/quality-profiles.ts b/server/sonar-web/src/main/js/queries/quality-profiles.ts index 1b4d40c3f83..572a54a3758 100644 --- a/server/sonar-web/src/main/js/queries/quality-profiles.ts +++ b/server/sonar-web/src/main/js/queries/quality-profiles.ts @@ -17,7 +17,13 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + UseQueryResult, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { ActivateRuleParameters, AddRemoveGroupParameters, @@ -30,8 +36,10 @@ import { compareProfiles, deactivateRule, getProfileInheritance, + getQualityProfile, } from '../api/quality-profiles'; import { ProfileInheritanceDetails } from '../types/types'; +import { createQueryHook } from './common'; export function useProfileInheritanceQuery( profile?: Pick, @@ -54,6 +62,17 @@ export function useProfileInheritanceQuery( }); } +export const useGetQualityProfile = createQueryHook( + (data: Parameters[0]) => { + return queryOptions({ + queryKey: ['quality-profile', 'details', data.profile, data.compareToSonarWay], + queryFn: () => { + return getQualityProfile(data); + }, + }); + }, +); + export function useProfilesCompareQuery(leftKey: string, rightKey: string) { return useQuery({ queryKey: ['quality-profiles', 'compare', leftKey, rightKey], diff --git a/server/sonar-web/src/main/js/queries/rules.ts b/server/sonar-web/src/main/js/queries/rules.ts index 46baf6bf217..07f83be487e 100644 --- a/server/sonar-web/src/main/js/queries/rules.ts +++ b/server/sonar-web/src/main/js/queries/rules.ts @@ -17,7 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + queryOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; import { createRule, deleteRule, getRuleDetails, searchRules, updateRule } from '../api/rules'; import { mapRestRuleToRule } from '../apps/coding-rules/utils'; import { SearchRulesResponse } from '../types/coding-rules'; @@ -33,8 +37,8 @@ function getRulesQueryKey(type: 'search' | 'details', data?: SearchRulesQuery | return key; } -export function useSearchRulesQuery(data: SearchRulesQuery) { - return useQuery({ +export const useSearchRulesQuery = createQueryHook((data: SearchRulesQuery) => { + return queryOptions({ queryKey: getRulesQueryKey('search', data), queryFn: ({ queryKey: [, , query] }) => { if (!query) { @@ -45,7 +49,7 @@ export function useSearchRulesQuery(data: SearchRulesQuery) { }, staleTime: StaleTime.NEVER, }); -} +}); export const useRuleDetailsQuery = createQueryHook((data: { actives?: boolean; key: string }) => { return queryOptions({ diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts index bc254a234c7..c0ce6ab4654 100644 --- a/server/sonar-web/src/main/js/types/types.ts +++ b/server/sonar-web/src/main/js/types/types.ts @@ -598,7 +598,14 @@ export interface RuleParameter { export type RuleScope = 'MAIN' | 'TEST' | 'ALL'; -export type RuleType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT' | 'UNKNOWN'; +export const RuleTypes = [ + 'BUG', + 'VULNERABILITY', + 'CODE_SMELL', + 'SECURITY_HOTSPOT', + 'UNKNOWN', +] as const; +export type RuleType = (typeof RuleTypes)[number]; export interface Snippet { end: number; -- 2.39.5