diff options
author | stanislavh <stanislav.honcharov@sonarsource.com> | 2023-10-05 10:04:28 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-10-10 20:02:44 +0000 |
commit | 047d3846d5e415786323625cd5101a9d54b04725 (patch) | |
tree | 714c5c79cd6baf2279b83e549ba590ef6a2081f0 /server | |
parent | a898251506b3b27006fa1cfca7c93042ebd857c0 (diff) | |
download | sonarqube-047d3846d5e415786323625cd5101a9d54b04725.tar.gz sonarqube-047d3846d5e415786323625cd5101a9d54b04725.zip |
SONAR-20547 Show new taxonomy in profile compare page
Diffstat (limited to 'server')
11 files changed, 443 insertions, 150 deletions
diff --git a/server/sonar-web/design-system/src/components/Text.tsx b/server/sonar-web/design-system/src/components/Text.tsx index 153fb3488c1..49bed8534a8 100644 --- a/server/sonar-web/design-system/src/components/Text.tsx +++ b/server/sonar-web/design-system/src/components/Text.tsx @@ -75,6 +75,14 @@ export function TextError({ text, className }: { className?: string; text: strin ); } +export function TextSuccess({ text, className }: Readonly<{ className?: string; text: string }>) { + return ( + <StyledTextSuccess className={className} title={text}> + {text} + </StyledTextSuccess> + ); +} + export const StyledText = styled.span` ${tw`sw-inline-block`}; ${tw`sw-truncate`}; @@ -104,6 +112,10 @@ const StyledTextError = styled(StyledText)` color: ${themeColor('danger')}; `; +const StyledTextSuccess = styled(StyledText)` + color: ${themeColor('textSuccess')}; +`; + export const DisabledText = styled.span` ${tw`sw-font-regular`}; color: ${themeColor('pageContentLight')}; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 94b817174db..bc368b517d7 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -76,6 +76,9 @@ export const lightTheme = { // danger danger: danger.dark, + // text + textSuccess: COLORS.yellowGreen[700], + //Project list card projectCardDisabled: COLORS.blueGrey[200], diff --git a/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts index 436f86b8542..d994b726b2d 100644 --- a/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts @@ -53,6 +53,7 @@ import { compareProfiles, copyProfile, createQualityProfile, + deactivateRule, deleteProfile, dissociateProject, getExporters, @@ -111,6 +112,7 @@ export default class QualityProfilesServiceMock { jest.mocked(searchRules).mockImplementation(this.handleSearchRules); jest.mocked(compareProfiles).mockImplementation(this.handleCompareQualityProfiles); jest.mocked(activateRule).mockImplementation(this.handleActivateRule); + jest.mocked(deactivateRule).mockImplementation(this.handleDeactivateRule); jest.mocked(getRuleDetails).mockImplementation(this.handleGetRuleDetails); jest.mocked(restoreQualityProfile).mockImplementation(this.handleRestoreQualityProfile); jest.mocked(searchUsers).mockImplementation(this.handleSearchUsers); @@ -556,10 +558,15 @@ export default class QualityProfilesServiceMock { rule: string; severity?: string; }): Promise<undefined> => { - const profile = this.listQualityProfile.find((profile) => profile.key === data.key) as Profile; - const keyFilter = profile.name === this.comparisonResult.left.name ? 'inRight' : 'inLeft'; + this.comparisonResult.inRight = this.comparisonResult.inRight.filter( + ({ key }) => key !== data.rule, + ); + + return this.reply(undefined); + }; - this.comparisonResult[keyFilter] = this.comparisonResult[keyFilter].filter( + handleDeactivateRule = (data: { key: string; rule: string }) => { + this.comparisonResult.inLeft = this.comparisonResult.inLeft.filter( ({ key }) => key !== data.rule, ); 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 d0c90c71881..9821c59aabd 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -22,6 +22,11 @@ import { Exporter, ProfileChangelogEvent } from '../apps/quality-profiles/types' import { csvEscape } from '../helpers/csv'; import { throwGlobalError } from '../helpers/error'; import { RequestData, getJSON, post, postJSON } from '../helpers/request'; +import { + CleanCodeAttributeCategory, + SoftwareImpactSeverity, + SoftwareQuality, +} from '../types/clean-code-taxonomy'; import { Dict, Paging, ProfileInheritanceDetails, UserSelected } from '../types/types'; export interface ProfileActions { @@ -187,17 +192,29 @@ export function getProfileChangelog( }); } +export interface RuleCompare { + key: string; + name: string; + cleanCodeAttributeCategory: CleanCodeAttributeCategory; + impacts: Array<{ + softwareQuality: SoftwareQuality; + severity: SoftwareImpactSeverity; + }>; + left?: { params: Dict<string>; severity: string }; + right?: { params: Dict<string>; severity: string }; +} + export interface CompareResponse { left: { name: string }; right: { name: string }; - inLeft: Array<{ key: string; name: string; severity: string }>; - inRight: Array<{ key: string; name: string; severity: string }>; - modified: Array<{ - key: string; - name: string; - left: { params: Dict<string>; severity: string }; - right: { params: Dict<string>; severity: string }; - }>; + inLeft: Array<RuleCompare>; + inRight: Array<RuleCompare>; + modified: Array< + RuleCompare & { + left: { params: Dict<string>; severity: string }; + right: { params: Dict<string>; severity: string }; + } + >; } export function compareProfiles(leftKey: string, rightKey: string): Promise<CompareResponse> { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx index e89ee45c2c1..b6cdf9fae7d 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx @@ -21,6 +21,7 @@ import { act, getByText, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import selectEvent from 'react-select-event'; import QualityProfilesServiceMock from '../../../api/mocks/QualityProfilesServiceMock'; +import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import { mockPaging, mockRule } from '../../../helpers/testMocks'; import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; import { byRole, byText } from '../../../helpers/testSelector'; @@ -30,9 +31,11 @@ jest.mock('../../../api/quality-profiles'); jest.mock('../../../api/rules'); const serviceMock = new QualityProfilesServiceMock(); +const settingsMock = new SettingsServiceMock(); beforeEach(() => { serviceMock.reset(); + settingsMock.reset(); }); const ui = { @@ -83,6 +86,11 @@ const ui = { byRole('button', { name: `quality_profiles.comparison.activate_rule.${profileName}`, }), + deactivateRuleButton: (profileName: string) => + byRole('button', { + name: `quality_profiles.comparison.deactivate_rule.${profileName}`, + }), + deactivateConfirmButton: byRole('button', { name: 'yes' }), activateConfirmButton: byRole('button', { name: 'coding_rules.activate' }), namePropupInput: byRole('textbox', { name: 'quality_profiles.new_name required' }), filterByLang: byRole('combobox', { name: 'quality_profiles.select_lang' }), @@ -103,6 +111,8 @@ const ui = { nameCreatePopupInput: byRole('textbox', { name: 'name required' }), importerA: byText('Importer A'), importerB: byText('Importer B'), + summaryAdditionalRules: (count: number) => byText(`quality_profile.summary_additional.${count}`), + summaryFewerRules: (count: number) => byText(`quality_profile.summary_fewer.${count}`), comparisonDiffTableHeading: (rulesQuantity: number, profileName: string) => byRole('columnheader', { name: `quality_profiles.x_rules_only_in.${rulesQuantity}.${profileName}`, @@ -321,18 +331,44 @@ it('should be able to compare profiles', async () => { expect(ui.changelogLink.query()).not.toBeInTheDocument(); await selectEvent.select(ui.compareDropdown.get(), 'java quality profile #2'); - expect(ui.comparisonDiffTableHeading(1, 'java quality profile').get()).toBeInTheDocument(); + expect(await ui.comparisonDiffTableHeading(1, 'java quality profile').find()).toBeInTheDocument(); expect(ui.comparisonDiffTableHeading(1, 'java quality profile #2').get()).toBeInTheDocument(); expect(ui.comparisonModifiedTableHeading(1).get()).toBeInTheDocument(); // java quality profile is not editable expect(ui.activeRuleButton('java quality profile').query()).not.toBeInTheDocument(); + expect(ui.deactivateRuleButton('java quality profile').query()).not.toBeInTheDocument(); +}); - await user.click(ui.activeRuleButton('java quality profile #2').get()); +it('should be able to activate or deactivate rules in comparison page', async () => { + // From the list page + const user = userEvent.setup(); + serviceMock.setAdmin(); + renderQualityProfiles(); + + await user.click(await ui.listProfileActions('java quality profile #2', 'Java').find()); + await user.click(ui.compareButton.get()); + await selectEvent.select(ui.compareDropdown.get(), 'java quality profile'); + + expect(await ui.summaryFewerRules(1).find()).toBeInTheDocument(); + expect(ui.summaryAdditionalRules(1).get()).toBeInTheDocument(); + + // Activate + await act(async () => { + await user.click(ui.activeRuleButton('java quality profile #2').get()); + }); expect(ui.popup.get()).toBeInTheDocument(); - await user.click(ui.activateConfirmButton.get()); - expect(ui.comparisonDiffTableHeading(1, 'java quality profile').query()).not.toBeInTheDocument(); + await act(async () => { + await user.click(ui.activateConfirmButton.get()); + }); + expect(ui.summaryFewerRules(1).query()).not.toBeInTheDocument(); + + // Deactivate + await user.click(await ui.deactivateRuleButton('java quality profile #2').find()); + expect(ui.popup.get()).toBeInTheDocument(); + await user.click(ui.deactivateConfirmButton.get()); + expect(ui.summaryAdditionalRules(1).query()).not.toBeInTheDocument(); }); function renderQualityProfiles() { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx index 260ea4a98ea..2ca0a012a30 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx @@ -19,8 +19,10 @@ */ import { Spinner } from 'design-system'; import * as React from 'react'; -import { compareProfiles, CompareResponse } from '../../../api/quality-profiles'; -import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; +import { useLocation, useRouter } from '../../../components/hoc/withRouter'; +import { useProfilesCompareQuery } from '../../../queries/quality-profiles'; +import { useGetValueQuery } from '../../../queries/settings'; +import { SettingsKey } from '../../../types/settings'; import { withQualityProfilesContext } from '../qualityProfilesContext'; import { Profile } from '../types'; import { getProfileComparePath } from '../utils'; @@ -30,99 +32,61 @@ import ComparisonResults from './ComparisonResults'; interface Props { profile: Profile; profiles: Profile[]; - location: Location; - router: Router; } -type State = { loading: boolean } & Partial<CompareResponse>; -type StateWithResults = { loading: boolean } & CompareResponse; +export function ComparisonContainer(props: Readonly<Props>) { + const { profile, profiles } = props; + const location = useLocation(); + const router = useRouter(); + const { data: inheritRulesSetting } = useGetValueQuery( + SettingsKey.QPAdminCanDisableInheritedRules, + ); + const canDeactivateInheritedRules = inheritRulesSetting?.value === 'true'; -class ComparisonContainer extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false }; + const { withKey } = location.query; + const { + data: compareResults, + isLoading, + refetch, + } = useProfilesCompareQuery(profile.key, withKey); - componentDidMount() { - this.mounted = true; - this.loadResults(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.profile !== this.props.profile || prevProps.location !== this.props.location) { - this.loadResults(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - loadResults = () => { - const { withKey } = this.props.location.query; - if (!withKey) { - this.setState({ left: undefined, loading: false }); - return Promise.resolve(); - } - - this.setState({ loading: true }); - return compareProfiles(this.props.profile.key, withKey).then( - ({ left, right, inLeft, inRight, modified }) => { - if (this.mounted) { - this.setState({ left, right, inLeft, inRight, modified, loading: false }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }, - ); + const handleCompare = (withKey: string) => { + const path = getProfileComparePath(profile.name, profile.language, withKey); + router.push(path); }; - handleCompare = (withKey: string) => { - const path = getProfileComparePath( - this.props.profile.name, - this.props.profile.language, - withKey, - ); - this.props.router.push(path); + const refresh = async () => { + await refetch(); }; - hasResults(state: State): state is StateWithResults { - return state.left !== undefined; - } + return ( + <div className="sw-body-sm"> + <div className="sw-flex sw-items-center"> + <ComparisonForm + onCompare={handleCompare} + profile={profile} + profiles={profiles} + withKey={withKey} + /> - render() { - const { profile, profiles, location } = this.props; - const { withKey } = location.query; - - return ( - <div className="sw-body-sm"> - <div className="sw-flex sw-items-center"> - <ComparisonForm - onCompare={this.handleCompare} - profile={profile} - profiles={profiles} - withKey={withKey} - /> - - <Spinner className="sw-ml-2" loading={this.state.loading} /> - </div> - - {this.hasResults(this.state) && ( - <ComparisonResults - inLeft={this.state.inLeft} - inRight={this.state.inRight} - left={this.state.left} - leftProfile={profile} - modified={this.state.modified} - refresh={this.loadResults} - right={this.state.right} - rightProfile={profiles.find((p) => p.key === withKey)} - /> - )} + <Spinner className="sw-ml-2" loading={isLoading} /> </div> - ); - } + + {compareResults && ( + <ComparisonResults + inLeft={compareResults.inLeft} + inRight={compareResults.inRight} + left={compareResults.left} + leftProfile={profile} + modified={compareResults.modified} + refresh={refresh} + right={compareResults.right} + rightProfile={profiles.find((p) => p.key === withKey)} + canDeactivateInheritedRules={canDeactivateInheritedRules} + /> + )} + </div> + ); } -export default withQualityProfilesContext(withRouter(ComparisonContainer)); +export default withQualityProfilesContext(ComparisonContainer); diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultDeactivation.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultDeactivation.tsx new file mode 100644 index 00000000000..8ff41c5fa1e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultDeactivation.tsx @@ -0,0 +1,79 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { DangerButtonSecondary } from 'design-system'; +import { noop } from 'lodash'; +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { Profile, deactivateRule } from '../../../api/quality-profiles'; +import ConfirmButton from '../../../components/controls/ConfirmButton'; +import Tooltip from '../../../components/controls/Tooltip'; + +interface Props { + onDone: () => Promise<void>; + profile: Profile; + ruleKey: string; + canDeactivateInheritedRules: boolean; +} + +export default function ComparisonResultDeactivation(props: React.PropsWithChildren<Props>) { + const { profile, ruleKey, canDeactivateInheritedRules } = props; + const intl = useIntl(); + + const handleDeactivate = () => { + const data = { + key: profile.key, + rule: ruleKey, + }; + deactivateRule(data).then(props.onDone, noop); + }; + + return ( + <ConfirmButton + confirmButtonText={intl.formatMessage({ id: 'yes' })} + modalBody={intl.formatMessage({ id: 'coding_rules.deactivate.confirm' })} + modalHeader={intl.formatMessage({ id: 'coding_rules.deactivate' })} + onConfirm={handleDeactivate} + > + {({ onClick }) => ( + <Tooltip + overlay={ + canDeactivateInheritedRules + ? intl.formatMessage( + { id: 'quality_profiles.comparison.deactivate_rule' }, + { profile: profile.name }, + ) + : intl.formatMessage({ id: 'coding_rules.can_not_deactivate' }) + } + > + <DangerButtonSecondary + disabled={!canDeactivateInheritedRules} + onClick={onClick} + aria-label={intl.formatMessage( + { id: 'quality_profiles.comparison.deactivate_rule' }, + { profile: profile.name }, + )} + > + {intl.formatMessage({ id: 'coding_rules.deactivate' })} + </DangerButtonSecondary> + </Tooltip> + )} + </ConfirmButton> + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResults.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResults.tsx index 6f643b5e8c7..800733aa422 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResults.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResults.tsx @@ -18,12 +18,19 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { ActionCell, ContentCell, Link, Table, TableRowInteractive } from 'design-system'; +import { isEqual } from 'lodash'; import * as React from 'react'; import { useIntl } from 'react-intl'; -import { CompareResponse, Profile } from '../../../api/quality-profiles'; +import { CompareResponse, Profile, RuleCompare } from '../../../api/quality-profiles'; +import IssueSeverityIcon from '../../../components/icon-mappers/IssueSeverityIcon'; +import { CleanCodeAttributePill } from '../../../components/shared/CleanCodeAttributePill'; +import SoftwareImpactPill from '../../../components/shared/SoftwareImpactPill'; import { getRulesUrl } from '../../../helpers/urls'; +import { IssueSeverity } from '../../../types/issues'; import { Dict } from '../../../types/types'; import ComparisonResultActivation from './ComparisonResultActivation'; +import ComparisonResultDeactivation from './ComparisonResultDeactivation'; +import ComparisonResultsSummary from './ComparisonResultsSummary'; type Params = Dict<string>; @@ -31,54 +38,33 @@ interface Props extends CompareResponse { leftProfile: Profile; refresh: () => Promise<void>; rightProfile?: Profile; + canDeactivateInheritedRules: boolean; } export default function ComparisonResults(props: Readonly<Props>) { - const { leftProfile, rightProfile, inLeft, left, right, inRight, modified } = props; + const { + leftProfile, + rightProfile, + inLeft, + left, + right, + inRight, + modified, + canDeactivateInheritedRules, + } = props; const intl = useIntl(); const emptyComparison = !inLeft.length && !inRight.length && !modified.length; - const canActivate = (profile: Profile) => - !profile.isBuiltIn && profile.actions && profile.actions.edit; - - const renderRule = React.useCallback((rule: { key: string; name: string }) => { - return ( - <div> - <Link className="sw-ml-1" to={getRulesUrl({ rule_key: rule.key, open: rule.key })}> - {rule.name} - </Link> - </div> - ); - }, []); - - const renderParameters = React.useCallback((params: Params) => { - if (!params) { - return null; - } - return ( - <ul> - {Object.keys(params).map((key) => ( - <li className="sw-mt-2 sw-break-all" key={key}> - <code className="sw-code"> - {key} - {': '} - {params[key]} - </code> - </li> - ))} - </ul> - ); - }, []); + const canEdit = (profile: Profile) => !profile.isBuiltIn && profile.actions?.edit; const renderLeft = () => { if (inLeft.length === 0) { return null; } - const renderSecondColumn = rightProfile && canActivate(rightProfile); - + const canRenderSecondColumn = leftProfile && canEdit(leftProfile); return ( <Table columnCount={2} @@ -94,7 +80,7 @@ export default function ComparisonResults(props: Readonly<Props>) { { count: inLeft.length, profile: left.name }, )} </ContentCell> - {renderSecondColumn && ( + {canRenderSecondColumn && ( <ContentCell aria-label={intl.formatMessage({ id: 'actions' })}> </ContentCell> )} </TableRowInteractive> @@ -102,14 +88,17 @@ export default function ComparisonResults(props: Readonly<Props>) { > {inLeft.map((rule) => ( <TableRowInteractive key={`left-${rule.key}`}> - <ContentCell>{renderRule(rule)}</ContentCell> - {renderSecondColumn && ( + <ContentCell> + <RuleCell rule={rule} /> + </ContentCell> + {canRenderSecondColumn && ( <ContentCell className="sw-px-0"> - <ComparisonResultActivation + <ComparisonResultDeactivation key={rule.key} onDone={props.refresh} - profile={rightProfile} + profile={leftProfile} ruleKey={rule.key} + canDeactivateInheritedRules={canDeactivateInheritedRules} /> </ContentCell> )} @@ -124,7 +113,7 @@ export default function ComparisonResults(props: Readonly<Props>) { return null; } - const renderFirstColumn = leftProfile && canActivate(leftProfile); + const renderFirstColumn = leftProfile && canEdit(leftProfile); return ( <Table @@ -159,7 +148,9 @@ export default function ComparisonResults(props: Readonly<Props>) { /> </ActionCell> )} - <ContentCell className="sw-pl-4">{renderRule(rule)}</ContentCell> + <ContentCell className="sw-pl-4"> + <RuleCell rule={rule} /> + </ContentCell> </TableRowInteractive> ))} </Table> @@ -195,14 +186,14 @@ export default function ComparisonResults(props: Readonly<Props>) { <TableRowInteractive key={`modified-${rule.key}`}> <ContentCell> <div> - {renderRule(rule)} - {renderParameters(rule.left.params)} + <RuleCell rule={rule} severity={rule.left.severity} /> + <Parameters params={rule.left.params} /> </div> </ContentCell> <ContentCell className="sw-pl-4"> <div> - {renderRule(rule)} - {renderParameters(rule.right.params)} + <RuleCell rule={rule} severity={rule.right.severity} /> + <Parameters params={rule.right.params} /> </div> </ContentCell> </TableRowInteractive> @@ -212,11 +203,17 @@ export default function ComparisonResults(props: Readonly<Props>) { }; return ( - <div className="sw-mt-4"> + <div className="sw-mt-8"> {emptyComparison ? ( intl.formatMessage({ id: 'quality_profile.empty_comparison' }) ) : ( <> + <ComparisonResultsSummary + profileName={leftProfile.name} + comparedProfileName={rightProfile?.name} + additionalCount={inLeft.length} + fewerCount={inRight.length} + /> {renderLeft()} {renderRight()} {renderModified()} @@ -225,3 +222,47 @@ export default function ComparisonResults(props: Readonly<Props>) { </div> ); } + +function RuleCell({ rule, severity }: Readonly<{ rule: RuleCompare; severity?: string }>) { + const shouldRenderSeverity = + Boolean(severity) && rule.left && rule.right && isEqual(rule.left.params, rule.right.params); + + return ( + <div> + {shouldRenderSeverity && <IssueSeverityIcon severity={severity as IssueSeverity} />} + <Link className="sw-ml-1" to={getRulesUrl({ rule_key: rule.key, open: rule.key })}> + {rule.name} + </Link> + <ul className="sw-mt-3 sw-flex sw-items-center"> + <li> + <CleanCodeAttributePill cleanCodeAttributeCategory={rule.cleanCodeAttributeCategory} /> + </li> + {rule.impacts.map(({ severity, softwareQuality }) => ( + <li key={softwareQuality} className="sw-ml-2"> + <SoftwareImpactPill type="rule" quality={softwareQuality} severity={severity} /> + </li> + ))} + </ul> + </div> + ); +} + +function Parameters({ params }: Readonly<{ params?: Params }>) { + if (!params) { + return null; + } + + return ( + <ul> + {Object.keys(params).map((key) => ( + <li className="sw-mt-2 sw-break-all" key={key}> + <code className="sw-body-sm"> + {key} + {': '} + {params[key]} + </code> + </li> + ))} + </ul> + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultsSummary.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultsSummary.tsx new file mode 100644 index 00000000000..31c89e24fcd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultsSummary.tsx @@ -0,0 +1,101 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { TextError, TextSuccess } from 'design-system'; +import React from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +interface Props { + profileName: string; + comparedProfileName?: string; + additionalCount: number; + fewerCount: number; +} + +export default function ComparisonResultsSummary(props: Readonly<Props>) { + const { profileName, comparedProfileName, additionalCount, fewerCount } = props; + const intl = useIntl(); + + if (additionalCount === 0 && fewerCount === 0) { + return null; + } + + if (additionalCount === 0 || fewerCount === 0) { + return ( + <div className="sw-mb-4"> + <FormattedMessage + id="quality_profile.summary_differences2" + values={{ + profile: profileName, + comparedProfile: comparedProfileName, + difference: + additionalCount === 0 ? ( + <TextError + className="sw-inline" + text={intl.formatMessage( + { id: 'quality_profile.summary_fewer' }, + { count: fewerCount }, + )} + /> + ) : ( + <TextSuccess + className="sw-inline" + text={intl.formatMessage( + { id: 'quality_profile.summary_additional' }, + { count: additionalCount }, + )} + /> + ), + }} + /> + </div> + ); + } + + return ( + <div className="sw-mb-4"> + <FormattedMessage + id="quality_profile.summary_differences1" + values={{ + profile: profileName, + comparedProfile: comparedProfileName, + additional: ( + <TextSuccess + className="sw-inline" + text={intl.formatMessage( + { id: 'quality_profile.summary_additional' }, + { count: additionalCount }, + )} + /> + ), + fewer: ( + <TextError + className="sw-inline" + text={intl.formatMessage( + { id: 'quality_profile.summary_fewer' }, + { count: fewerCount }, + )} + /> + ), + }} + /> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index f49e2011d1f..1b593839bdd 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -481,18 +481,37 @@ export function mockCompareResult(overrides: Partial<CompareResponse> = {}): Com { key: 'java:S4604', name: 'Rule in left', - severity: 'MINOR', + cleanCodeAttributeCategory: CleanCodeAttributeCategory.Adaptable, + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + ], }, ], inRight: [ { key: 'java:S5128', name: 'Rule in right', - severity: 'MAJOR', + cleanCodeAttributeCategory: CleanCodeAttributeCategory.Responsible, + impacts: [ + { + softwareQuality: SoftwareQuality.Security, + severity: SoftwareImpactSeverity.Medium, + }, + ], }, ], modified: [ { + cleanCodeAttributeCategory: CleanCodeAttributeCategory.Consistent, + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Low, + }, + ], key: 'java:S1698', name: '== and != should not be used when equals is overridden', left: { params: {}, severity: 'MINOR' }, 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 0396f4bfe67..cc5367e3838 100644 --- a/server/sonar-web/src/main/js/queries/quality-profiles.ts +++ b/server/sonar-web/src/main/js/queries/quality-profiles.ts @@ -24,6 +24,7 @@ import { Profile, addGroup, addUser, + compareProfiles, getProfileInheritance, } from '../api/quality-profiles'; import { ProfileInheritanceDetails } from '../types/types'; @@ -49,6 +50,19 @@ export function useProfileInheritanceQuery( }); } +export function useProfilesCompareQuery(leftKey: string, rightKey: string) { + return useQuery({ + queryKey: ['quality-profiles', 'compare', leftKey, rightKey], + queryFn: ({ queryKey: [, , leftKey, rightKey] }) => { + if (!leftKey || !rightKey) { + return null; + } + + return compareProfiles(leftKey, rightKey); + }, + }); +} + export function useAddUserMutation(onSuccess: () => unknown) { return useMutation({ mutationFn: (data: AddRemoveUserParameters) => addUser(data), |