aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorstanislavh <stanislav.honcharov@sonarsource.com>2023-10-05 10:04:28 +0200
committersonartech <sonartech@sonarsource.com>2023-10-10 20:02:44 +0000
commit047d3846d5e415786323625cd5101a9d54b04725 (patch)
tree714c5c79cd6baf2279b83e549ba590ef6a2081f0 /server
parenta898251506b3b27006fa1cfca7c93042ebd857c0 (diff)
downloadsonarqube-047d3846d5e415786323625cd5101a9d54b04725.tar.gz
sonarqube-047d3846d5e415786323625cd5101a9d54b04725.zip
SONAR-20547 Show new taxonomy in profile compare page
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/design-system/src/components/Text.tsx12
-rw-r--r--server/sonar-web/design-system/src/theme/light.ts3
-rw-r--r--server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts13
-rw-r--r--server/sonar-web/src/main/js/api/quality-profiles.ts33
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx44
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx136
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultDeactivation.tsx79
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResults.tsx135
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultsSummary.tsx101
-rw-r--r--server/sonar-web/src/main/js/helpers/testMocks.ts23
-rw-r--r--server/sonar-web/src/main/js/queries/quality-profiles.ts14
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' })}>&nbsp;</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),