From: Viktor Vorona Date: Fri, 4 Oct 2024 11:00:41 +0000 (+0200) Subject: SONAR-23261 Custom software quality severities X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=2b278b7029b326ed61d505a32ca11b35294dace5;p=sonarqube.git SONAR-23261 Custom software quality severities --- diff --git a/server/sonar-web/design-system/src/components/input/FormField.tsx b/server/sonar-web/design-system/src/components/input/FormField.tsx index be9a4afa452..f40334f0339 100644 --- a/server/sonar-web/design-system/src/components/input/FormField.tsx +++ b/server/sonar-web/design-system/src/components/input/FormField.tsx @@ -31,6 +31,7 @@ interface Props { children: ReactNode; className?: string; description?: string | ReactNode; + disabled?: boolean; help?: ReactNode; htmlFor?: string; id?: string; @@ -44,6 +45,7 @@ export function FormField({ children, className, description, + disabled, help, id, required, @@ -56,7 +58,7 @@ export function FormField({ return ( - + {label} {required && ( @@ -74,8 +76,9 @@ export function FormField({ // This is needed to prevent the target input/button from being focused // when clicking/hovering on the label. More info https://stackoverflow.com/questions/9098581/why-is-hover-for-input-triggered-on-corresponding-label-in-css -const StyledLabel = styled(Label)` +const StyledLabel = styled(Label)<{ disabled?: boolean }>` pointer-events: none; + color: ${({ disabled }) => (disabled ? 'var(--echoes-color-text-disabled)' : 'inherit')}; `; const FieldWrapper = styled.div` diff --git a/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts index 7c869ba07c0..3235f698a0c 100644 --- a/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts @@ -31,8 +31,9 @@ import { mockRuleActivation, mockRuleRepository, } from '../../helpers/testMocks'; +import { SoftwareImpactSeverity, SoftwareQuality } from '../../types/clean-code-taxonomy'; import { RuleRepository, SearchRulesResponse } from '../../types/coding-rules'; -import { RawIssuesResponse } from '../../types/issues'; +import { IssueSeverity, RawIssuesResponse } from '../../types/issues'; import { RuleStatus, SearchRulesQuery } from '../../types/rules'; import { SecurityStandard } from '../../types/security'; import { @@ -108,6 +109,22 @@ const FACET_RULE_MAP: { [key: string]: keyof Rule } = { tags: 'tags', }; +const MQRtoStandardSeverityMap = { + [SoftwareImpactSeverity.Info]: IssueSeverity.Info, + [SoftwareImpactSeverity.Low]: IssueSeverity.Minor, + [SoftwareImpactSeverity.Medium]: IssueSeverity.Major, + [SoftwareImpactSeverity.High]: IssueSeverity.Critical, + [SoftwareImpactSeverity.Blocker]: IssueSeverity.Blocker, +}; + +const StandardtoMQRSeverityMap = { + [IssueSeverity.Info]: SoftwareImpactSeverity.Info, + [IssueSeverity.Minor]: SoftwareImpactSeverity.Low, + [IssueSeverity.Major]: SoftwareImpactSeverity.Medium, + [IssueSeverity.Critical]: SoftwareImpactSeverity.High, + [IssueSeverity.Blocker]: SoftwareImpactSeverity.Blocker, +}; + export const RULE_TAGS_MOCK = ['awesome', 'cute', 'nice']; export default class CodingRulesServiceMock { @@ -568,6 +585,12 @@ export default class CodingRulesServiceMock { activation.inherit = 'INHERITED'; activation.prioritizedRule = parentActivation?.prioritizedRule ?? false; activation.severity = parentActivation?.severity ?? 'MAJOR'; + activation.impacts = parentActivation?.impacts ?? [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + ]; activation.params = parentParams; return this.reply(undefined); @@ -582,16 +605,53 @@ export default class CodingRulesServiceMock { currentActivation.params, Object.entries(data.params ?? {}).map(([key, value]) => ({ key, value })), ) && - currentActivation.severity === data.severity && - currentActivation.prioritizedRule === data.prioritizedRule + (!data.severity || currentActivation.severity === data.severity) && + currentActivation.prioritizedRule === data.prioritizedRule && + (!data.softwareQualityImpact || + isEqual( + currentActivation.impacts, + Object.entries(data.softwareQualityImpact).map(([softwareQuality, severity]) => ({ + softwareQuality, + severity, + })), + )) ) { return this.reply(undefined); } + const ruleImpacts = this.rules.find((r) => r.key === data.rule)?.impacts ?? []; + const inheritedImpacts = + this.rulesActivations[data.rule]?.find(({ qProfile }) => qProfile === data.key)?.impacts ?? + []; + const severity = data.softwareQualityImpact + ? MQRtoStandardSeverityMap[data.softwareQualityImpact[SoftwareQuality.Maintainability]] + : data.severity; + const impacts = data.severity + ? [ + ...ruleImpacts.filter( + (impact) => !inheritedImpacts.some((i) => i.softwareQuality === impact.softwareQuality), + ), + ...inheritedImpacts.filter( + (impact) => impact.softwareQuality !== SoftwareQuality.Maintainability, + ), + { + softwareQuality: SoftwareQuality.Maintainability, + severity: + StandardtoMQRSeverityMap[data.severity as keyof typeof StandardtoMQRSeverityMap], + }, + ] + : Object.entries(data.softwareQualityImpact ?? {}).map( + ([softwareQuality, severity]: [SoftwareQuality, SoftwareImpactSeverity]) => ({ + softwareQuality, + severity, + }), + ); + const nextActivations = [ mockRuleActivation({ qProfile: data.key, - severity: data.severity, + severity, + impacts, prioritizedRule: data.prioritizedRule, params: Object.entries(data.params ?? {}).map(([key, value]) => ({ key, value })), }), @@ -604,7 +664,8 @@ export default class CodingRulesServiceMock { ...inheritingProfiles.map((profile) => mockRuleActivation({ qProfile: profile.key, - severity: data.severity, + severity, + impacts, prioritizedRule: data.prioritizedRule, inherit: 'INHERITED', params: Object.entries(data.params ?? {}).map(([key, value]) => ({ key, value })), diff --git a/server/sonar-web/src/main/js/api/mocks/data/rules.ts b/server/sonar-web/src/main/js/api/mocks/data/rules.ts index 12d827391a8..b32fc913940 100644 --- a/server/sonar-web/src/main/js/api/mocks/data/rules.ts +++ b/server/sonar-web/src/main/js/api/mocks/data/rules.ts @@ -101,6 +101,12 @@ export function mockRuleDetailsList() { { key: '1', type: 'TEXT', htmlDesc: 'html description for key 1' }, { key: '2', type: 'NUMBER', defaultValue: 'default value for key 2' }, ], + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + ], }), mockRuleDetails({ key: RULE_2, @@ -157,6 +163,12 @@ export function mockRuleDetailsList() { content: resourceContent, }, ], + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + ], }), mockRuleDetails({ key: RULE_6, @@ -166,6 +178,16 @@ export function mockRuleDetailsList() { name: 'Bad Python rule', isExternal: true, descriptionSections: undefined, + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + { + softwareQuality: SoftwareQuality.Security, + severity: SoftwareImpactSeverity.Low, + }, + ], }), mockRuleDetails({ key: RULE_7, @@ -194,6 +216,12 @@ export function mockRuleDetailsList() { content: resourceContent, }, ], + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Low, + }, + ], }), mockRuleDetails({ key: RULE_8, @@ -213,6 +241,9 @@ export function mockRuleDetailsList() { key: RULE_9, type: 'BUG', severity: 'MINOR', + impacts: [ + { softwareQuality: SoftwareQuality.Reliability, severity: SoftwareImpactSeverity.Low }, + ], lang: 'py', langName: 'Python', tags: ['awesome', 'cute'], @@ -240,6 +271,16 @@ export function mockRuleDetailsList() { content: resourceContent, }, ], + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Low, + }, + { + softwareQuality: SoftwareQuality.Reliability, + severity: SoftwareImpactSeverity.High, + }, + ], educationPrinciples: ['defense_in_depth', 'never_trust_user_input'], }), mockRuleDetails({ @@ -266,7 +307,19 @@ export function mockRuleDetailsList() { export function mockRulesActivationsInQP() { return { [RULE_1]: [mockRuleActivation({ qProfile: QP_1 }), mockRuleActivation({ qProfile: QP_6 })], - [RULE_7]: [mockRuleActivation({ qProfile: QP_2 })], + [RULE_7]: [ + mockRuleActivation({ + qProfile: QP_2, + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + { softwareQuality: SoftwareQuality.Reliability, severity: SoftwareImpactSeverity.High }, + { softwareQuality: SoftwareQuality.Security, severity: SoftwareImpactSeverity.High }, + ], + }), + ], [RULE_8]: [mockRuleActivation({ qProfile: QP_2 })], [RULE_9]: [ mockRuleActivation({ @@ -276,11 +329,41 @@ export function mockRulesActivationsInQP() { { key: '2', value: 'default value for key 2' }, ], inherit: 'INHERITED', + impacts: [ + { softwareQuality: SoftwareQuality.Reliability, severity: SoftwareImpactSeverity.Medium }, + ], }), ], [RULE_10]: [ - mockRuleActivation({ qProfile: QP_2, inherit: 'OVERRIDES', prioritizedRule: true }), - mockRuleActivation({ qProfile: QP_2_Parent, severity: 'MINOR' }), + mockRuleActivation({ + qProfile: QP_2, + inherit: 'OVERRIDES', + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Medium, + }, + { + softwareQuality: SoftwareQuality.Reliability, + severity: SoftwareImpactSeverity.Info, + }, + ], + prioritizedRule: true, + }), + mockRuleActivation({ + qProfile: QP_2_Parent, + severity: 'MINOR', + impacts: [ + { + softwareQuality: SoftwareQuality.Maintainability, + severity: SoftwareImpactSeverity.Low, + }, + { + softwareQuality: SoftwareQuality.Reliability, + severity: SoftwareImpactSeverity.Blocker, + }, + ], + }), ], [RULE_11]: [ mockRuleActivation({ qProfile: QP_4 }), 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 d2b2898e330..124b2b0abe0 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -23,7 +23,12 @@ import { getJSON } from '~sonar-aligned/helpers/request'; import { Exporter, ProfileChangelogEvent } from '../apps/quality-profiles/types'; import { csvEscape } from '../helpers/csv'; import { RequestData, post, postJSON } from '../helpers/request'; -import { CleanCodeAttributeCategory, SoftwareImpact } from '../types/clean-code-taxonomy'; +import { + CleanCodeAttributeCategory, + SoftwareImpact, + SoftwareImpactSeverity, + SoftwareQuality, +} from '../types/clean-code-taxonomy'; import { Dict, Paging, ProfileInheritanceDetails, UserSelected } from '../types/types'; export interface ProfileActions { @@ -322,17 +327,25 @@ export function bulkDeactivateRules(data: BulkActivateParameters) { export interface ActivateRuleParameters { key: string; - params?: Dict; + params?: Record; prioritizedRule?: boolean; reset?: boolean; rule: string; severity?: string; + softwareQualityImpact?: Record; } export function activateRule(data: ActivateRuleParameters) { const params = data.params && map(data.params, (value, key) => `${key}=${csvEscape(value)}`).join(';'); - return post('/api/qualityprofiles/activate_rule', { ...data, params }).catch(throwGlobalError); + const softwareQualityImpact = + data.softwareQualityImpact && + map(data.softwareQualityImpact, (value, key) => `${key}=${value}`).join(';'); + return post('/api/qualityprofiles/activate_rule', { + ...data, + params, + softwareQualityImpact, + }).catch(throwGlobalError); } export interface DeactivateRuleParameters { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts index 7e559096a1f..7b59e1ef06b 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts +++ b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts @@ -20,7 +20,7 @@ import { fireEvent, screen, within } from '@testing-library/react'; import CodingRulesServiceMock, { RULE_TAGS_MOCK } from '../../../api/mocks/CodingRulesServiceMock'; import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; -import { QP_2, RULE_1, RULE_10, RULE_9 } from '../../../api/mocks/data/ids'; +import { QP_2, RULE_1, RULE_10, RULE_7, RULE_9 } from '../../../api/mocks/data/ids'; import { IMPACT_SEVERITIES, ISSUE_TYPES, @@ -244,11 +244,11 @@ describe('Rules app list', () => { // Filter by software quality await user.click(ui.facetItem('software_quality.MAINTAINABILITY').get()); - expect(ui.getAllRuleListItems()).toHaveLength(9); + expect(ui.getAllRuleListItems()).toHaveLength(8); // Filter by severity await user.click(ui.facetItem(/severity_impact.HIGH/).get()); - expect(ui.getAllRuleListItems()).toHaveLength(8); + expect(ui.getAllRuleListItems()).toHaveLength(4); }); it('filter by type and severity in standard mode', async () => { @@ -408,132 +408,363 @@ describe('Rules app list', () => { }); }); - it('can activate/change/deactivate specific rule for quality profile', async () => { - const { ui, user } = getPageObjects(); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); - await ui.appLoaded(); + describe('old severity', () => { + it('can activate/change/deactivate specific rule for quality profile', async () => { + const { ui, user } = getPageObjects(); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); + await ui.appLoaded(); - await user.click(ui.qpFacet.get()); - await user.click(ui.facetItem('QP Bar Python').get()); + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); + + // Only 4 rules are activated in selected QP + expect(ui.getAllRuleListItems()).toHaveLength(4); - // Only 4 rules are activated in selected QP - expect(ui.getAllRuleListItems()).toHaveLength(4); + // Switch to inactive rules + await user.click(ui.qpInactiveRadio.get(ui.facetItem('QP Bar Python').get())); + expect(ui.getAllRuleListItems()).toHaveLength(2); + expect(ui.activateButton.getAll()).toHaveLength(2); + expect(ui.changeButton(QP_2).query()).not.toBeInTheDocument(); - // Switch to inactive rules - await user.click(ui.qpInactiveRadio.get(ui.facetItem('QP Bar Python').get())); - expect(ui.getAllRuleListItems()).toHaveLength(2); - expect(ui.activateButton.getAll()).toHaveLength(2); - expect(ui.changeButton(QP_2).query()).not.toBeInTheDocument(); + // Activate Rule for qp + await user.click(ui.activateButton.getAll()[0]); + expect(ui.oldSeveritySelect.get(ui.activateQPDialog.get())).toHaveValue( + 'coding_rules.custom_severity.severity_with_recommended.severity.MAJOR', + ); + expect(ui.prioritizedSwitch.get(ui.activateQPDialog.get())).not.toBeChecked(); + await user.click(ui.oldSeveritySelect.get()); + await user.click(byRole('option', { name: 'severity.MINOR' }).get()); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MAJOR'); + + await user.click(ui.prioritizedSwitch.get(ui.activateQPDialog.get())); + await user.click(ui.activateButton.get(ui.activateQPDialog.get())); + + expect(ui.activateButton.getAll()).toHaveLength(1); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + expect(ui.deactivateButton.getAll()).toHaveLength(1); + + // Change Rule for qp + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue('severity.MINOR'); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MAJOR'); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).toBeChecked(); + await user.click(ui.oldSeveritySelect.get()); + await user.click(byRole('option', { name: 'severity.BLOCKER' }).get()); + await user.click(ui.prioritizedSwitch.get(ui.changeQPDialog.get())); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MAJOR'); + await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + + // Check that new severity is saved + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue('severity.BLOCKER'); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MAJOR'); + await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + + // Deactivate activated rule + await user.click(ui.deactivateButton.get()); + await user.click(ui.yesButton.get()); + expect(ui.deactivateButton.query()).not.toBeInTheDocument(); + expect(ui.activateButton.getAll()).toHaveLength(2); + }); - // Activate Rule for qp - await user.click(ui.activateButton.getAll()[0]); - expect(ui.selectValue.get(ui.activateQPDialog.get())).toHaveTextContent('severity.MAJOR'); - expect(ui.prioritizedSwitch.get(ui.activateQPDialog.get())).not.toBeChecked(); - await user.click(ui.oldSeveritySelect.get()); - await user.click(byRole('option', { name: 'severity.MINOR' }).get()); + it('can revert to parent definition specific rule for quality profile', async () => { + const { ui, user } = getPageObjects(); + settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); + await ui.appLoaded(); - await user.click(ui.prioritizedSwitch.get(ui.activateQPDialog.get())); - await user.click(ui.activateButton.get(ui.activateQPDialog.get())); + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); - expect(ui.activateButton.getAll()).toHaveLength(1); - expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); - expect(ui.deactivateButton.getAll()).toHaveLength(1); - - // Change Rule for qp - await user.click(ui.changeButton('QP Bar').get()); - expect(ui.selectValue.get(ui.changeQPDialog.get())).toHaveTextContent('severity.MINOR'); - expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).toBeChecked(); - await user.click(ui.oldSeveritySelect.get()); - await user.click(byRole('option', { name: 'severity.BLOCKER' }).get()); - await user.click(ui.prioritizedSwitch.get(ui.changeQPDialog.get())); - await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + // Only 4 rules are activated in selected QP + expect(ui.getAllRuleListItems()).toHaveLength(4); - // Check that new severity is saved - await user.click(ui.changeButton('QP Bar').get()); - expect(ui.selectValue.get(ui.changeQPDialog.get())).toHaveTextContent('severity.BLOCKER'); - expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); - await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + // 3 rules have deactivate button and 1 rule has revert to parent definition button + expect(ui.deactivateButton.getAll()).toHaveLength(3); + expect(ui.revertToParentDefinitionButton.get()).toBeInTheDocument(); - // Deactivate activated rule - await user.click(ui.deactivateButton.get()); - await user.click(ui.yesButton.get()); - expect(ui.deactivateButton.query()).not.toBeInTheDocument(); - expect(ui.activateButton.getAll()).toHaveLength(2); + await user.type(ui.searchInput.get(), RULE_10); + + // Only 1 rule left after search + expect(ui.getAllRuleListItems()).toHaveLength(1); + expect(ui.revertToParentDefinitionButton.get()).toBeInTheDocument(); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + // Check that severity is reflected correctly + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue('severity.MAJOR'); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MINOR'); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).toBeChecked(); + await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + + await user.click(ui.revertToParentDefinitionButton.get()); + await user.click(ui.yesButton.get()); + + expect(ui.getAllRuleListItems()).toHaveLength(1); + expect(ui.revertToParentDefinitionButton.query()).not.toBeInTheDocument(); + expect(ui.deactivateButton.get()).toBeInTheDocument(); + expect(ui.deactivateButton.get()).toBeDisabled(); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + // Check that severity is reflected correctly + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue( + 'coding_rules.custom_severity.severity_with_recommended.severity.MINOR', + ); + expect(ui.notRecommendedSeverity.query()).not.toBeInTheDocument(); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); + await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + }); + + it('should not make rule overriden if no changes were done', async () => { + const { ui, user } = getPageObjects(); + settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); + await ui.appLoaded(); + + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); + + // filter out everything except INHERITED rule + await user.type(ui.searchInput.get(), RULE_9); + + // Only 1 rule left after search + expect(ui.getAllRuleListItems()).toHaveLength(1); + expect(ui.deactivateButton.get()).toBeInTheDocument(); + expect(ui.deactivateButton.get()).toBeDisabled(); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + // Check that severity is reflected correctly + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue('severity.MAJOR'); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MINOR'); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); + await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + + expect(ui.revertToParentDefinitionButton.query()).not.toBeInTheDocument(); + }); }); - it('can revert to parent definition specific rule for quality profile', async () => { - const { ui, user } = getPageObjects(); - settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); - await ui.appLoaded(); + describe('new severity', () => { + it('can activate/change specific rule with multiple impacts for quality profile', async () => { + const { ui, user } = getPageObjects(); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, []); + await ui.appLoaded(); - await user.click(ui.qpFacet.get()); - await user.click(ui.facetItem('QP Bar Python').get()); + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); + await user.click(ui.qpInactiveRadio.get(ui.facetItem('QP Bar Python').get())); + + // Activate Rule for qp + await user.click(ui.activateButton.getAll()[1]); - // Only 4 rules are activated in selected QP - expect(ui.getAllRuleListItems()).toHaveLength(4); + await user.click(ui.mqrSwitch.get(ui.activateQPDialog.get())); - // 3 rules have deactivate button and 1 rule has revert to parent definition button - expect(ui.deactivateButton.getAll()).toHaveLength(3); - expect(ui.revertToParentDefinitionButton.get()).toBeInTheDocument(); + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'coding_rules.custom_severity.severity_with_recommended.severity_impact.MEDIUM', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toHaveValue( + 'coding_rules.custom_severity.severity_with_recommended.severity_impact.LOW', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toBeDisabled(); + await user.click(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()); + await user.click(byRole('option', { name: 'severity_impact.LOW' }).get()); + await user.click(ui.newSeveritySelect(SoftwareQuality.Security).get()); + await user.click(byRole('option', { name: 'severity_impact.MEDIUM' }).get()); + expect(ui.notRecommendedSeverity.getAll()).toHaveLength(2); + expect(ui.notRecommendedSeverity.getAt(0)).toHaveTextContent('severity_impact.LOW'); + expect(ui.notRecommendedSeverity.getAt(1)).toHaveTextContent('severity_impact.MEDIUM'); + + await user.click(ui.activateButton.get(ui.activateQPDialog.get())); + + expect(ui.activateButton.getAll()).toHaveLength(1); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + expect(ui.deactivateButton.getAll()).toHaveLength(1); + + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue('severity.MINOR'); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MAJOR'); + await user.click(ui.mqrSwitch.get()); + + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'severity_impact.LOW', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toHaveValue( + 'severity_impact.MEDIUM', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toBeDisabled(); + expect(ui.notRecommendedSeverity.getAll()).toHaveLength(2); + expect(ui.notRecommendedSeverity.getAt(0)).toHaveTextContent('severity_impact.LOW'); + expect(ui.notRecommendedSeverity.getAt(1)).toHaveTextContent('severity_impact.MEDIUM'); + await user.click(ui.newSeveritySelect(SoftwareQuality.Security).get()); + await user.click(byRole('option', { name: 'severity_impact.BLOCKER' }).get()); + expect(ui.notRecommendedSeverity.getAll()).toHaveLength(2); + expect(ui.notRecommendedSeverity.getAt(0)).toHaveTextContent('severity_impact.LOW'); + expect(ui.notRecommendedSeverity.getAt(1)).toHaveTextContent('severity_impact.MEDIUM'); + await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + + // Check that new severity is saved + await user.click(ui.changeButton('QP Bar').get()); + await user.click(ui.mqrSwitch.get()); + expect(ui.oldSeveritySelect.get(ui.changeQPDialog.get())).toHaveValue('severity.MINOR'); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity.MAJOR'); + await user.click(ui.mqrSwitch.get()); + + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'severity_impact.LOW', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toHaveValue( + 'severity_impact.BLOCKER', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toBeDisabled(); - await user.type(ui.searchInput.get(), RULE_10); + await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + }); - // Only 1 rule left after search - expect(ui.getAllRuleListItems()).toHaveLength(1); - expect(ui.revertToParentDefinitionButton.get()).toBeInTheDocument(); - expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + it('can revert to parent definition specific rule for quality profile', async () => { + const { ui, user } = getPageObjects(); + settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); + await ui.appLoaded(); - // Check that severity is reflected correctly - await user.click(ui.changeButton('QP Bar').get()); - expect(ui.selectValue.get(ui.changeQPDialog.get())).toHaveTextContent('severity.MAJOR'); - expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).toBeChecked(); - await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); - await user.click(ui.revertToParentDefinitionButton.get()); - await user.click(ui.yesButton.get()); + // Only 4 rules are activated in selected QP + expect(ui.getAllRuleListItems()).toHaveLength(4); - expect(ui.getAllRuleListItems()).toHaveLength(1); - expect(ui.revertToParentDefinitionButton.query()).not.toBeInTheDocument(); - expect(ui.deactivateButton.get()).toBeInTheDocument(); - expect(ui.deactivateButton.get()).toBeDisabled(); - expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + // 3 rules have deactivate button and 1 rule has revert to parent definition button + expect(ui.deactivateButton.getAll()).toHaveLength(3); + expect(ui.revertToParentDefinitionButton.get()).toBeInTheDocument(); - // Check that severity is reflected correctly - await user.click(ui.changeButton('QP Bar').get()); - expect(ui.selectValue.get(ui.changeQPDialog.get())).toHaveTextContent('severity.MINOR'); - expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); - await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); - }); + await user.type(ui.searchInput.get(), RULE_10); - it('should not make rule overriden if no changes were done', async () => { - const { ui, user } = getPageObjects(); - settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser(), undefined, [Feature.PrioritizedRules]); - await ui.appLoaded(); + // Only 1 rule left after search + expect(ui.getAllRuleListItems()).toHaveLength(1); + expect(ui.revertToParentDefinitionButton.get()).toBeInTheDocument(); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + // Check that severity is reflected correctly + await user.click(ui.changeButton('QP Bar').get()); + await user.click(ui.mqrSwitch.get(ui.changeQPDialog.get())); + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'severity_impact.MEDIUM', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toHaveValue( + 'severity_impact.INFO', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toBeDisabled(); + expect(ui.notRecommendedSeverity.getAll()).toHaveLength(2); + expect(ui.notRecommendedSeverity.getAt(0)).toHaveTextContent('severity_impact.HIGH'); + expect(ui.notRecommendedSeverity.getAt(1)).toHaveTextContent('severity_impact.LOW'); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).toBeChecked(); + await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); - await user.click(ui.qpFacet.get()); - await user.click(ui.facetItem('QP Bar Python').get()); + await user.click(ui.revertToParentDefinitionButton.get()); + await user.click(ui.yesButton.get()); - // filter out everything except INHERITED rule - await user.type(ui.searchInput.get(), RULE_9); + expect(ui.getAllRuleListItems()).toHaveLength(1); + expect(ui.revertToParentDefinitionButton.query()).not.toBeInTheDocument(); + expect(ui.deactivateButton.get()).toBeInTheDocument(); + expect(ui.deactivateButton.get()).toBeDisabled(); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + // Check that severity is reflected correctly + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'coding_rules.custom_severity.severity_with_recommended.severity_impact.LOW', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toHaveValue( + 'severity_impact.BLOCKER', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toBeDisabled(); + expect(ui.notRecommendedSeverity.getAll()).toHaveLength(1); + expect(ui.notRecommendedSeverity.getAt(0)).toHaveTextContent('severity_impact.HIGH'); + expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); + await user.click(ui.cancelButton.get(ui.changeQPDialog.get())); + }); - // Only 1 rule left after search - expect(ui.getAllRuleListItems()).toHaveLength(1); - expect(ui.deactivateButton.get()).toBeInTheDocument(); - expect(ui.deactivateButton.get()).toBeDisabled(); - expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + it('should not make rule overriden if no changes were done', async () => { + const { ui, user } = getPageObjects(); + settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, []); + await ui.appLoaded(); - // Check that severity is reflected correctly - await user.click(ui.changeButton('QP Bar').get()); - expect(ui.selectValue.get(ui.changeQPDialog.get())).toHaveTextContent('severity.MAJOR'); - expect(ui.prioritizedSwitch.get(ui.changeQPDialog.get())).not.toBeChecked(); - await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); + + // filter out everything except INHERITED rule + await user.type(ui.searchInput.get(), RULE_9); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + // Check that severity is reflected correctly + await user.click(ui.changeButton('QP Bar').get()); + await user.click(ui.mqrSwitch.get(ui.changeQPDialog.get())); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toHaveValue( + 'severity_impact.MEDIUM', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toBeDisabled(); + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toBeDisabled(); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity_impact.LOW'); + await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + + expect(ui.revertToParentDefinitionButton.query()).not.toBeInTheDocument(); + }); - expect(ui.revertToParentDefinitionButton.query()).not.toBeInTheDocument(); + it('should ignore excessive activation impacts', async () => { + const { ui, user } = getPageObjects(); + settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), undefined, []); + await ui.appLoaded(); + + await user.click(ui.qpFacet.get()); + await user.click(ui.facetItem('QP Bar Python').get()); + + await user.type(ui.searchInput.get(), RULE_7); + expect(ui.changeButton('QP Bar').get()).toBeInTheDocument(); + + await user.click(ui.changeButton('QP Bar').get()); + await user.click(ui.mqrSwitch.get(ui.changeQPDialog.get())); + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'severity_impact.MEDIUM', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toBeDisabled(); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toBeDisabled(); + expect(ui.notRecommendedSeverity.get()).toBeInTheDocument(); + expect(ui.notRecommendedSeverity.get()).toHaveTextContent('severity_impact.LOW'); + await user.click(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()); + await user.click( + byRole('option', { + name: 'coding_rules.custom_severity.severity_with_recommended.severity_impact.LOW', + }).get(), + ); + await user.click(ui.saveButton.get(ui.changeQPDialog.get())); + + await user.click(ui.changeButton('QP Bar').get()); + expect(ui.newSeveritySelect(SoftwareQuality.Maintainability).get()).toHaveValue( + 'coding_rules.custom_severity.severity_with_recommended.severity_impact.LOW', + ); + expect(ui.newSeveritySelect(SoftwareQuality.Security).get()).toBeDisabled(); + expect(ui.newSeveritySelect(SoftwareQuality.Reliability).get()).toBeDisabled(); + expect(ui.notRecommendedSeverity.query()).not.toBeInTheDocument(); + }); }); it('should not show prioritized rule switcher if feature is not enabled', async () => { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationFormModal.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationFormModal.tsx index efa190f33f0..a06bb85d86d 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationFormModal.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationFormModal.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Button, ButtonVariety, Modal } from '@sonarsource/echoes-react'; +import { Button, ButtonVariety, Checkbox, Modal, Text } from '@sonarsource/echoes-react'; import { FlagMessage, FormField, @@ -32,15 +32,16 @@ import { Switch, } from 'design-system'; import * as React from 'react'; -import { useIntl } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { Profile } from '../../../api/quality-profiles'; import { useAvailableFeatures } from '../../../app/components/available-features/withAvailableFeatures'; import DocumentationLink from '../../../components/common/DocumentationLink'; import { DocLink } from '../../../helpers/doc-links'; import { useActivateRuleMutation } from '../../../queries/quality-profiles'; +import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy'; import { Feature } from '../../../types/features'; import { IssueSeverity } from '../../../types/issues'; -import { Dict, Rule, RuleActivation, RuleDetails } from '../../../types/types'; +import { Rule, RuleActivation, RuleDetails } from '../../../types/types'; import { sortProfiles } from '../../quality-profiles/utils'; import { SeveritySelect } from './SeveritySelect'; @@ -82,6 +83,10 @@ export default function ActivationFormModal(props: Readonly) { const [changedSeverity, setChangedSeverity] = React.useState( undefined, ); + const [changedImpactSeveritiesMap, setChangedImpactSeverities] = React.useState< + Map + >(new Map()); + const [isMQRMode, setIsMQRMode] = React.useState(false); const profilesWithDepth = React.useMemo(() => { return getQualityProfilesWithDepth(profiles, rule.lang); @@ -93,6 +98,13 @@ export default function ActivationFormModal(props: Readonly) { const params = changedParams ?? getRuleParams({ activation, rule }); const severity = changedSeverity ?? ((activation ? activation.severity : rule.severity) as IssueSeverity); + const impacts = new Map([ + ...rule.impacts.map((impact) => [impact.softwareQuality, impact.severity] as const), + ...(activation?.impacts + ?.filter((impact) => rule.impacts.some((i) => i.softwareQuality === impact.softwareQuality)) + .map((impact) => [impact.softwareQuality, impact.severity] as const) ?? []), + ...changedImpactSeveritiesMap, + ]); const profileOptions = profilesWithDepth.map((p) => ({ label: p.name, value: p })); const isCustomRule = !!(rule as RuleDetails).templateKey; const activeInAllProfiles = profilesWithDepth.length <= 0; @@ -104,6 +116,7 @@ export default function ActivationFormModal(props: Readonly) { setChangedProfile(undefined); setChangedParams(undefined); setChangedSeverity(undefined); + setChangedImpactSeverities(new Map()); } }, [isOpen]); @@ -113,8 +126,11 @@ export default function ActivationFormModal(props: Readonly) { key: profile?.key ?? '', params, rule: rule.key, - severity, + severity: !isMQRMode ? severity : undefined, prioritizedRule, + softwareQualityImpact: isMQRMode + ? (Object.fromEntries(impacts) as Record) + : undefined, }; activateRule(data); }; @@ -194,47 +210,109 @@ export default function ActivationFormModal(props: Readonly) { } > - + checked={prioritizedRule} + /> )} - - ) => { - setChangedSeverity(value); - }} - severity={severity} - /> - -
- {intl.formatMessage({ id: 'coding_rules.severity_deprecated' })} - - {intl.formatMessage({ id: 'learn_more' })} - -
-
+ + + {!isMQRMode && ( + <> + + + + {intl.formatMessage({ + id: 'coding_rules.custom_severity.description.standard.link', + })} + + ), + }} + /> + + + + + { + setChangedSeverity(value as IssueSeverity); + }} + severity={severity} + /> + + + )} + + {isMQRMode && ( + <> + + + + {intl.formatMessage({ + id: 'coding_rules.custom_severity.description.mqr.link', + })} + + ), + }} + /> + + + + {Object.values(SoftwareQuality).map((quality) => { + const impact = rule.impacts.find((impact) => impact.softwareQuality === quality); + const id = `coding-rules-custom-severity-${quality}-select`; + return ( + + { + setChangedImpactSeverities( + new Map(changedImpactSeveritiesMap).set( + quality, + value as SoftwareImpactSeverity, + ), + ); + }} + severity={impacts.get(quality) ?? ''} + /> + + ); + })} + + )} + {isCustomRule ? ( {intl.formatMessage({ id: 'coding_rules.custom_rule.activation_notice' })} @@ -308,7 +386,7 @@ function getRuleParams({ activation?: RuleActivation; rule: RuleDetails | Rule; }) { - const params: Dict = {}; + const params: Record = {}; if (rule?.params) { for (const param of rule.params) { params[param.key] = param.defaultValue ?? ''; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/SeveritySelect.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/SeveritySelect.tsx index 6aa134c43cc..2822ee68ab4 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/SeveritySelect.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/SeveritySelect.tsx @@ -17,59 +17,73 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { InputSelect, LabelValueSelectOption } from 'design-system'; +import { HelperText, Select } from '@sonarsource/echoes-react'; +import { isEmpty } from 'lodash'; import * as React from 'react'; -import { OptionProps, SingleValueProps, components } from 'react-select'; -import SeverityHelper from '../../../components/shared/SeverityHelper'; +import { FormattedMessage, useIntl } from 'react-intl'; +import SeverityIcon from '../../../components/icon-mappers/SeverityIcon'; +import SoftwareImpactSeverityIcon from '../../../components/icon-mappers/SoftwareImpactSeverityIcon'; import { SEVERITIES } from '../../../helpers/constants'; -import { translate } from '../../../helpers/l10n'; -import { IssueSeverity } from '../../../types/issues'; +import { SoftwareImpactSeverity } from '../../../types/clean-code-taxonomy'; export interface SeveritySelectProps { + id: string; + impactSeverity?: boolean; isDisabled: boolean; - onChange: (value: LabelValueSelectOption) => void; + onChange: (value: string) => void; + recommendedSeverity: string; severity: string; } -function Option(props: Readonly, false>>) { - // For tests and a11y - props.innerProps.role = 'option'; - props.innerProps['aria-selected'] = props.isSelected; - - return ( - - - - ); -} - -function SingleValue( - props: Readonly, false>>, -) { - return ( - - - - ); -} - export function SeveritySelect(props: SeveritySelectProps) { - const { isDisabled, severity } = props; - const serverityOption = SEVERITIES.map((severity) => ({ - label: translate('severity', severity), - value: severity, - })); + const { isDisabled, severity, recommendedSeverity, impactSeverity, id } = props; + const intl = useIntl(); + const Icon = impactSeverity ? SoftwareImpactSeverityIcon : SeverityIcon; + const getSeverityTranslation = (severity: string) => + impactSeverity + ? intl.formatMessage({ id: `severity_impact.${severity}` }) + : intl.formatMessage({ id: `severity.${severity}` }); + const serverityOption = (impactSeverity ? Object.values(SoftwareImpactSeverity) : SEVERITIES).map( + (severity) => ({ + label: + severity === recommendedSeverity + ? intl.formatMessage( + { id: 'coding_rules.custom_severity.severity_with_recommended' }, + { severity: getSeverityTranslation(severity) }, + ) + : getSeverityTranslation(severity), + value: severity, + prefix: , + }), + ); return ( - s.value === severity)} - /> + <> +