@@ -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')}; |
@@ -76,6 +76,9 @@ export const lightTheme = { | |||
// danger | |||
danger: danger.dark, | |||
// text | |||
textSuccess: COLORS.yellowGreen[700], | |||
//Project list card | |||
projectCardDisabled: COLORS.blueGrey[200], | |||
@@ -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, | |||
); | |||
@@ -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> { |
@@ -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() { |
@@ -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); |
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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' }, |
@@ -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), |
@@ -2005,7 +2005,11 @@ quality_profiles.warning.is_default_no_rules=The current profile is the default | |||
quality_profiles.x_sonarway_missing_rules={linkCount} Sonar way {count, plural, one {rule} other {rules}} not included | |||
quality_profiles.parent=Parent: | |||
quality_profiles.parameter_set_to=Parameter {0} set to {1} | |||
quality_profiles.x_rules_only_in={count} rules only in {profile} | |||
quality_profile.summary_additional={count} additional {count, plural, one {rule} other {rules}} | |||
quality_profile.summary_fewer={count} fewer {count, plural, one {rule} other {rules}} | |||
quality_profile.summary_differences1={profile} has {additional} and {fewer} than {comparedProfile}. | |||
quality_profile.summary_differences2={profile} has {difference} than {comparedProfile} | |||
quality_profiles.x_rules_only_in={count} rules in {profile} | |||
quality_profiles.x_rules_have_different_configuration={count} rules have a different configuration | |||
quality_profiles.copy_x_title=Copy Profile "{0}" - {1} | |||
quality_profiles.extend_x_title=Extend Profile "{0}" - {1} | |||
@@ -2044,6 +2048,7 @@ quality_profiles.activate_more=Activate More | |||
quality_profiles.activate_more.help.built_in=This quality profile is built in, and cannot be updated manually. If you want to activate more rules, create a new profile that inherits from this one and add rules there. | |||
quality_profiles.activate_more_rules=Activate More Rules | |||
quality_profiles.comparison.activate_rule=Activate rule for profile "{profile}" | |||
quality_profiles.comparison.deactivate_rule=Dectivate rule for profile "{profile}" | |||
quality_profiles.intro=Quality profiles are collections of rules to apply during an analysis. For each language there is a default profile. All projects not explicitly assigned to some other profile will be analyzed with the default. Ideally, all projects will use the same profile for a language. | |||
quality_profiles.list.projects=Projects | |||
quality_profiles.list.projects.help=Projects assigned to a profile will always be analyzed with it for that language, regardless of which profile is the default. Quality profile administrators may assign projects to a non-default profile, or always make it follow the system default. Project administrators may choose any profile for each language. |