diff options
author | Viktor Vorona <viktor.vorona@sonarsource.com> | 2024-10-04 13:00:41 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-10-16 20:03:00 +0000 |
commit | 2b278b7029b326ed61d505a32ca11b35294dace5 (patch) | |
tree | 0cfbd42759f1e93b77874f3653ea937c886789f6 /server/sonar-web | |
parent | 8c192bf316bb264513ae8e5e36a6cf19a776ab6f (diff) | |
download | sonarqube-2b278b7029b326ed61d505a32ca11b35294dace5.tar.gz sonarqube-2b278b7029b326ed61d505a32ca11b35294dace5.zip |
SONAR-23261 Custom software quality severities
Diffstat (limited to 'server/sonar-web')
13 files changed, 711 insertions, 207 deletions
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 ( <FieldWrapper className={className} id={id}> <Highlight className="sw-mb-2 sw-flex sw-items-center sw-gap-2"> - <StyledLabel aria-label={ariaLabel} htmlFor={htmlFor} title={title}> + <StyledLabel aria-label={ariaLabel} disabled={disabled} htmlFor={htmlFor} title={title}> {label} {required && ( <RequiredIcon aria-label={requiredAriaLabel ?? 'required'} className="sw-ml-1" /> @@ -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<string>; + params?: Record<string, string>; prioritizedRule?: boolean; reset?: boolean; rule: string; severity?: string; + softwareQualityImpact?: Record<SoftwareQuality, SoftwareImpactSeverity>; } 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<Props>) { const [changedSeverity, setChangedSeverity] = React.useState<IssueSeverity | undefined>( undefined, ); + const [changedImpactSeveritiesMap, setChangedImpactSeverities] = React.useState< + Map<SoftwareQuality, SoftwareImpactSeverity> + >(new Map()); + const [isMQRMode, setIsMQRMode] = React.useState<boolean>(false); const profilesWithDepth = React.useMemo(() => { return getQualityProfilesWithDepth(profiles, rule.lang); @@ -93,6 +98,13 @@ export default function ActivationFormModal(props: Readonly<Props>) { const params = changedParams ?? getRuleParams({ activation, rule }); const severity = changedSeverity ?? ((activation ? activation.severity : rule.severity) as IssueSeverity); + const impacts = new Map<SoftwareQuality, SoftwareImpactSeverity>([ + ...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<Props>) { setChangedProfile(undefined); setChangedParams(undefined); setChangedSeverity(undefined); + setChangedImpactSeverities(new Map()); } }, [isOpen]); @@ -113,8 +126,11 @@ export default function ActivationFormModal(props: Readonly<Props>) { key: profile?.key ?? '', params, rule: rule.key, - severity, + severity: !isMQRMode ? severity : undefined, prioritizedRule, + softwareQualityImpact: isMQRMode + ? (Object.fromEntries(impacts) as Record<SoftwareQuality, SoftwareImpactSeverity>) + : undefined, }; activateRule(data); }; @@ -194,47 +210,109 @@ export default function ActivationFormModal(props: Readonly<Props>) { </div> } > - <label + <Checkbox + onCheck={(checked) => setChangedPrioritizedRule(!!checked)} + label={intl.formatMessage({ id: 'coding_rules.prioritized_rule.switch_label' })} id="coding-rules-prioritized-rule" - className="sw-flex sw-items-center sw-gap-2" - > - <Switch - onChange={setChangedPrioritizedRule} - name={intl.formatMessage({ id: 'coding_rules.prioritized_rule.title' })} - value={prioritizedRule} - /> - <span className="sw-text-xs"> - {intl.formatMessage({ id: 'coding_rules.prioritized_rule.switch_label' })} - </span> - </label> + checked={prioritizedRule} + /> </FormField> )} - <FormField - ariaLabel={intl.formatMessage({ id: 'severity_deprecated' })} - label={intl.formatMessage({ id: 'severity_deprecated' })} - htmlFor="coding-rules-severity-select" - > - <SeveritySelect - isDisabled={submitting} - onChange={({ value }: LabelValueSelectOption<IssueSeverity>) => { - setChangedSeverity(value); - }} - severity={severity} - /> - <FlagMessage className="sw-mb-4 sw-mt-2" variant="info"> - <div> - {intl.formatMessage({ id: 'coding_rules.severity_deprecated' })} - <DocumentationLink - className="sw-ml-2 sw-whitespace-nowrap" - to={DocLink.CleanCodeIntroduction} - > - {intl.formatMessage({ id: 'learn_more' })} - </DocumentationLink> - </div> - </FlagMessage> + <FormField label="MQR Mode"> + <Switch value={isMQRMode} onChange={setIsMQRMode} /> </FormField> + {!isMQRMode && ( + <> + <FormField label={intl.formatMessage({ id: 'coding_rules.custom_severity.title' })}> + <Text> + <FormattedMessage + id="coding_rules.custom_severity.description.standard" + values={{ + link: ( + <DocumentationLink to={DocLink.RuleSeverity}> + {intl.formatMessage({ + id: 'coding_rules.custom_severity.description.standard.link', + })} + </DocumentationLink> + ), + }} + /> + </Text> + </FormField> + + <FormField + ariaLabel={intl.formatMessage({ + id: 'coding_rules.custom_severity.choose_severity', + })} + label={intl.formatMessage({ id: 'coding_rules.custom_severity.choose_severity' })} + htmlFor="coding-rules-custom-severity-select" + > + <SeveritySelect + id="coding-rules-custom-severity-select" + isDisabled={submitting} + recommendedSeverity={rule.severity} + onChange={(value: string) => { + setChangedSeverity(value as IssueSeverity); + }} + severity={severity} + /> + </FormField> + </> + )} + + {isMQRMode && ( + <> + <FormField label={intl.formatMessage({ id: 'coding_rules.custom_severity.title' })}> + <Text> + <FormattedMessage + id="coding_rules.custom_severity.description.mqr" + values={{ + link: ( + <DocumentationLink to={DocLink.RuleSeverity}> + {intl.formatMessage({ + id: 'coding_rules.custom_severity.description.mqr.link', + })} + </DocumentationLink> + ), + }} + /> + </Text> + </FormField> + + {Object.values(SoftwareQuality).map((quality) => { + const impact = rule.impacts.find((impact) => impact.softwareQuality === quality); + const id = `coding-rules-custom-severity-${quality}-select`; + return ( + <FormField + htmlFor={id} + key={quality} + disabled={!impact} + ariaLabel={intl.formatMessage({ id: `software_quality.${quality}` })} + label={intl.formatMessage({ id: `software_quality.${quality}` })} + > + <SeveritySelect + id={id} + impactSeverity + isDisabled={submitting || !impact} + recommendedSeverity={impact?.severity ?? ''} + onChange={(value: string) => { + setChangedImpactSeverities( + new Map(changedImpactSeveritiesMap).set( + quality, + value as SoftwareImpactSeverity, + ), + ); + }} + severity={impacts.get(quality) ?? ''} + /> + </FormField> + ); + })} + </> + )} + {isCustomRule ? ( <Note as="p" className="sw-my-4"> {intl.formatMessage({ id: 'coding_rules.custom_rule.activation_notice' })} @@ -308,7 +386,7 @@ function getRuleParams({ activation?: RuleActivation; rule: RuleDetails | Rule; }) { - const params: Dict<string> = {}; + const params: Record<string, string> = {}; 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<IssueSeverity>) => void; + onChange: (value: string) => void; + recommendedSeverity: string; severity: string; } -function Option(props: Readonly<OptionProps<LabelValueSelectOption<IssueSeverity>, false>>) { - // For tests and a11y - props.innerProps.role = 'option'; - props.innerProps['aria-selected'] = props.isSelected; - - return ( - <components.Option {...props}> - <SeverityHelper className="sw-flex sw-items-center" severity={props.data.value} /> - </components.Option> - ); -} - -function SingleValue( - props: Readonly<SingleValueProps<LabelValueSelectOption<IssueSeverity>, false>>, -) { - return ( - <components.SingleValue {...props}> - <SeverityHelper className="sw-flex sw-items-center" severity={props.data.value} /> - </components.SingleValue> - ); -} - 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: <Icon severity={severity} aria-hidden />, + }), + ); return ( - <InputSelect - aria-label={translate('severity')} - inputId="coding-rules-severity-select" - isDisabled={isDisabled} - onChange={props.onChange} - components={{ Option, SingleValue }} - options={serverityOption} - isSearchable={false} - value={serverityOption.find((s) => s.value === severity)} - /> + <> + <Select + id={id} + isDisabled={isDisabled} + onChange={props.onChange} + data={serverityOption} + isSearchable={false} + isNotClearable + placeholder={ + isDisabled && !isEmpty(severity) ? intl.formatMessage({ id: 'not_impacted' }) : undefined + } + value={severity} + valueIcon={<Icon severity={severity} aria-hidden />} + /> + {severity !== recommendedSeverity && ( + <HelperText className="sw-mt-2"> + <FormattedMessage + id="coding_rules.custom_severity.not_recommended" + values={{ + recommended: ( + <b className="sw-lowercase">{getSeverityTranslation(recommendedSeverity)}</b> + ), + }} + /> + </HelperText> + )} + </> ); } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/utils-tests.tsx b/server/sonar-web/src/main/js/apps/coding-rules/utils-tests.tsx index 10e845a5d04..ddfc1ffe0fc 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/utils-tests.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/utils-tests.tsx @@ -156,10 +156,13 @@ const selectors = { name: /coding_rules.deactivate_in_quality_profile/, hidden: true, }), - oldSeveritySelect: byLabelText('severity'), qualityProfileSelect: byLabelText('coding_rules.quality_profile'), - prioritizedSwitch: byRole('switch', { hidden: true }), - selectValue: byText(/severity\./), + oldSeveritySelect: byRole('combobox', { name: 'coding_rules.custom_severity.choose_severity' }), + mqrSwitch: byRole('switch'), + newSeveritySelect: (quality: SoftwareQuality) => + byRole('combobox', { name: `software_quality.${quality}` }), + notRecommendedSeverity: byText('coding_rules.custom_severity.not_recommended'), + prioritizedSwitch: byRole('checkbox', { name: 'coding_rules.prioritized_rule.switch_label' }), activateQPDialog: byRole('dialog', { name: 'coding_rules.activate_in_quality_profile' }), changeButton: (profile: string) => byRole('button', { name: `coding_rules.change_details_x.${profile}` }), diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/changelog/SoftwareImpactChange.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/changelog/SoftwareImpactChange.tsx index 92c717b29cd..ea1a59fae19 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/changelog/SoftwareImpactChange.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/changelog/SoftwareImpactChange.tsx @@ -19,6 +19,7 @@ */ import * as React from 'react'; import { useIntl } from 'react-intl'; +import SoftwareImpactSeverityIcon from '../../../components/icon-mappers/SoftwareImpactSeverityIcon'; import { ProfileChangelogEventImpactChange } from '../types'; interface Props { @@ -31,9 +32,19 @@ export default function SoftwareImpactChange({ impactChange }: Readonly<Props>) const intl = useIntl(); const labels = { - oldSeverity: intl.formatMessage({ id: `severity.${oldSeverity}` }), + oldSeverity: ( + <> + <SoftwareImpactSeverityIcon severity={oldSeverity} />{' '} + {intl.formatMessage({ id: `severity_impact.${oldSeverity}` })} + </> + ), oldSoftwareQuality: intl.formatMessage({ id: `software_quality.${oldSoftwareQuality}` }), - newSeverity: intl.formatMessage({ id: `severity.${newSeverity}` }), + newSeverity: ( + <> + <SoftwareImpactSeverityIcon severity={newSeverity} />{' '} + {intl.formatMessage({ id: `severity_impact.${newSeverity}` })} + </> + ), newSoftwareQuality: intl.formatMessage({ id: `software_quality.${newSoftwareQuality}` }), }; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/changelog/__tests__/ChangelogContainer-it.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/changelog/__tests__/ChangelogContainer-it.tsx index c77099634cc..b1d4c2b24ff 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/changelog/__tests__/ChangelogContainer-it.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/changelog/__tests__/ChangelogContainer-it.tsx @@ -92,8 +92,8 @@ it('should see the changelog', async () => { ui.checkRow(3, '', '', '', 'Rule 1', [ /quality_profiles.deprecated_severity_set_to severity.CRITICAL/, /quality_profiles.changelog.cca_and_category_changed.*COMPLETE.*INTENTIONAL.*LAWFUL.*RESPONSIBLE/, - /quality_profiles.changelog.impact_added.severity.*MEDIUM.*RELIABILITY/, - /quality_profiles.changelog.impact_removed.severity.HIGH.*MAINTAINABILITY/, + /quality_profiles.changelog.impact_added.severity_impact.*MEDIUM.*RELIABILITY/, + /quality_profiles.changelog.impact_removed.severity_impact.HIGH.*MAINTAINABILITY/, ]); await user.click(ui.link.get(rows[1])); expect(screen.getByText('/coding_rules?rule_key=c%3Arule0')).toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/helpers/doc-links.ts b/server/sonar-web/src/main/js/helpers/doc-links.ts index 8d5efa930a0..cce7a923191 100644 --- a/server/sonar-web/src/main/js/helpers/doc-links.ts +++ b/server/sonar-web/src/main/js/helpers/doc-links.ts @@ -74,6 +74,7 @@ export enum DocLink { PullRequestAnalysis = '/analyzing-source-code/pull-request-analysis/introduction/', QualityGates = '/instance-administration/analysis-functions/quality-gates/', Root = '/', + RuleSeverity = '/instance-administration/analysis-functions/quality-profiles/#rule-severity', RulesOverview = '/user-guide/rules/overview', SecurityHotspots = '/user-guide/security-hotspots/', SecurityReports = '/user-guide/viewing-reports/security-reports/', diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index 7050f6de7e6..2408be0427c 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -641,6 +641,9 @@ export function mockRuleActivation(overrides: Partial<RuleActivation> = {}): Rul qProfile: 'baz', severity: 'MAJOR', prioritizedRule: false, + impacts: [ + { softwareQuality: SoftwareQuality.Maintainability, severity: SoftwareImpactSeverity.Medium }, + ], ...overrides, }; } diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts index c0ce6ab4654..bd13aa9c219 100644 --- a/server/sonar-web/src/main/js/types/types.ts +++ b/server/sonar-web/src/main/js/types/types.ts @@ -25,6 +25,8 @@ import { CleanCodeAttribute, CleanCodeAttributeCategory, SoftwareImpact, + SoftwareImpactSeverity, + SoftwareQuality, } from './clean-code-taxonomy'; import { MessageFormatting, RawIssue } from './issues'; import { NewCodeDefinitionType } from './new-code-definition'; @@ -519,6 +521,7 @@ export interface RestRule { export interface RuleActivation { createdAt: string; + impacts: { severity: SoftwareImpactSeverity; softwareQuality: SoftwareQuality }[]; inherit: RuleInheritance; params: { key: string; value: string }[]; prioritizedRule: boolean; |