From b39f4b637522349a6c5c1c69fb19274205e48819 Mon Sep 17 00:00:00 2001 From: stanislavh Date: Tue, 5 Dec 2023 11:04:12 +0100 Subject: [PATCH] SONAR-21131 Add CCT attribute and software qualities fields in dialog Co-authored-by: Benjamin Raymond <31401273+7PH@users.noreply.github.com> --- .../src/components/input/Checkbox.tsx | 24 +- .../js/api/mocks/CodingRulesServiceMock.ts | 3 +- .../src/main/js/api/quality-profiles.ts | 11 +- server/sonar-web/src/main/js/api/rules.ts | 7 +- .../coding-rules/__tests__/CodingRules-it.ts | 118 +-------- .../coding-rules/__tests__/CustomRule-it.ts | 154 ++++++++++++ .../components/CustomRuleFormFieldsCCT.tsx | 223 ++++++++++++++++++ .../components/CustomRuleFormModal.tsx | 121 ++++------ .../main/js/apps/coding-rules/utils-tests.tsx | 28 ++- .../apps/web-api-v2/components/ApiSidebar.tsx | 4 +- .../shared/SoftwareImpactPillList.tsx | 13 +- .../src/main/js/helpers/constants.ts | 26 ++ .../src/main/js/helpers/testMocks.ts | 2 +- .../src/main/js/types/clean-code-taxonomy.ts | 5 + server/sonar-web/src/main/js/types/issues.ts | 8 +- server/sonar-web/src/main/js/types/types.ts | 14 +- .../resources/org/sonar/l10n/core.properties | 3 + 17 files changed, 519 insertions(+), 245 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/coding-rules/__tests__/CustomRule-it.ts create mode 100644 server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormFieldsCCT.tsx diff --git a/server/sonar-web/design-system/src/components/input/Checkbox.tsx b/server/sonar-web/design-system/src/components/input/Checkbox.tsx index f4c917c51cf..b6dc56e99fc 100644 --- a/server/sonar-web/design-system/src/components/input/Checkbox.tsx +++ b/server/sonar-web/design-system/src/components/input/Checkbox.tsx @@ -75,13 +75,11 @@ export function Checkbox({ onFocus={onFocus} type="checkbox" /> -
- - - - - -
+ + + + + {!right && children} ); @@ -145,33 +143,33 @@ export const AccessibleCheckbox = styled.input` &:focus, &:active { - &:not(:disabled) + div > ${StyledCheckbox} { + &:not(:disabled) ~ ${StyledCheckbox} { outline: ${themeBorder('focus', 'primary')}; } } &:checked { - & + div > ${StyledCheckbox} { + & ~ ${StyledCheckbox} { background: ${themeColor('primary')}; } - &:disabled + div > ${StyledCheckbox} { + &:disabled ~ ${StyledCheckbox} { background: ${themeColor('checkboxDisabledChecked')}; } } &:hover { - &:not(:disabled) + div > ${StyledCheckbox} { + &:not(:disabled) ~ ${StyledCheckbox} { background: ${themeColor('checkboxHover')}; border: ${themeBorder('default', 'primary')}; } - &:checked:not(:disabled) + div > ${StyledCheckbox} { + &:checked:not(:disabled) ~ ${StyledCheckbox} { background: ${themeColor('checkboxCheckedHover')}; border: ${themeBorder('default', 'checkboxCheckedHover')}; } } - &:disabled + div > ${StyledCheckbox} { + &:disabled ~ ${StyledCheckbox} { background: ${themeColor('checkboxDisabled')}; color: ${themeColor('checkboxDisabled')}; border: ${themeBorder('default', 'checkboxDisabledChecked')}; 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 28bbeb45838..e7f50d93529 100644 --- a/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts @@ -357,7 +357,6 @@ export default class CodingRulesServiceMock { : rule.remFnBaseEffort; rule.remFnType = data.remediation_fn_type !== undefined ? data.remediation_fn_type : rule.remFnType; - rule.severity = data.severity !== undefined ? data.severity : rule.severity; rule.status = data.status !== undefined ? data.status : rule.status; rule.tags = data.tags !== undefined ? data.tags.split(',') : rule.tags; @@ -369,7 +368,7 @@ export default class CodingRulesServiceMock { descriptionSections: [ { key: RuleDescriptionSections.DEFAULT, content: data.markdownDescription }, ], - ...pick(data, ['templateKey', 'severity', 'type', 'name', 'status']), + ...pick(data, ['templateKey', 'name', 'status', 'cleanCodeAttribute', 'impacts']), key: data.customKey, params: data.params?.split(';').map((param: string) => { 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 8f10efc1fcc..880b633d124 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -22,11 +22,7 @@ import { Exporter, ProfileChangelogEvent } from '../apps/quality-profiles/types' import { csvEscape } from '../helpers/csv'; import { throwGlobalError } from '../helpers/error'; import { RequestData, getJSON, post, postJSON } from '../helpers/request'; -import { - CleanCodeAttributeCategory, - SoftwareImpactSeverity, - SoftwareQuality, -} from '../types/clean-code-taxonomy'; +import { CleanCodeAttributeCategory, SoftwareImpact } from '../types/clean-code-taxonomy'; import { Dict, Paging, ProfileInheritanceDetails, UserSelected } from '../types/types'; export interface ProfileActions { @@ -196,10 +192,7 @@ export interface RuleCompare { key: string; name: string; cleanCodeAttributeCategory?: CleanCodeAttributeCategory; - impacts: Array<{ - softwareQuality: SoftwareQuality; - severity: SoftwareImpactSeverity; - }>; + impacts: SoftwareImpact[]; left?: { params: Dict; severity: string }; right?: { params: Dict; severity: string }; } diff --git a/server/sonar-web/src/main/js/api/rules.ts b/server/sonar-web/src/main/js/api/rules.ts index 87c1103d6e4..f8cbf1c301e 100644 --- a/server/sonar-web/src/main/js/api/rules.ts +++ b/server/sonar-web/src/main/js/api/rules.ts @@ -19,9 +19,10 @@ */ import { throwGlobalError } from '../helpers/error'; import { getJSON, post, postJSON } from '../helpers/request'; +import { CleanCodeAttribute, SoftwareImpact } from '../types/clean-code-taxonomy'; import { GetRulesAppResponse, SearchRulesResponse } from '../types/coding-rules'; import { SearchRulesQuery } from '../types/rules'; -import { RuleActivation, RuleDetails, RuleType, RulesUpdateRequest } from '../types/types'; +import { RuleActivation, RuleDetails, RulesUpdateRequest } from '../types/types'; export interface CreateRuleData { customKey: string; @@ -29,10 +30,10 @@ export interface CreateRuleData { name: string; params?: string; preventReactivation?: boolean; - severity?: string; status?: string; templateKey: string; - type?: RuleType; + cleanCodeAttribute: CleanCodeAttribute; + impacts: SoftwareImpact[]; } export function getRulesApp(): Promise { 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 d0c0d7726ae..f718d41646a 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 @@ -24,16 +24,13 @@ import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import { QP_2, RULE_1 } from '../../../api/mocks/data/ids'; import { CLEAN_CODE_CATEGORIES, SOFTWARE_QUALITIES } from '../../../helpers/constants'; import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks'; -import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; import { CleanCodeAttribute, CleanCodeAttributeCategory, SoftwareQuality, } from '../../../types/clean-code-taxonomy'; import { SettingsKey } from '../../../types/settings'; -import { CurrentUser } from '../../../types/users'; -import routes from '../routes'; -import { getPageObjects } from '../utils-tests'; +import { getPageObjects, renderCodingRulesApp } from '../utils-tests'; const rulesHandler = new CodingRulesServiceMock(); const settingsHandler = new SettingsServiceMock(); @@ -57,7 +54,7 @@ describe('Rules app list', () => { // Render clean code attributes. expect( - ui.ruleCleanCodeAttributeCategory(CleanCodeAttributeCategory.Adaptable).getAll().length, + ui.ruleCleanCodeAttributeCategory(CleanCodeAttributeCategory.Intentional).getAll().length, ).toBeGreaterThan(1); expect(ui.ruleSoftwareQuality(SoftwareQuality.Maintainability).getAll().length).toBeGreaterThan( 1, @@ -175,7 +172,7 @@ describe('Rules app list', () => { expect(ui.getAllRuleListItems()).toHaveLength(11); // Filter by clean code category - await user.click(ui.facetItem('issue.clean_code_attribute_category.ADAPTABLE').get()); + await user.click(ui.facetItem('issue.clean_code_attribute_category.INTENTIONAL').get()); expect(ui.getAllRuleListItems()).toHaveLength(10); @@ -383,7 +380,7 @@ describe('Rule app details', () => { await ui.detailsloaded(); expect(ui.ruleTitle('Awsome java rule').get()).toBeInTheDocument(); expect( - ui.ruleCleanCodeAttributeCategory(CleanCodeAttributeCategory.Adaptable).get(), + ui.ruleCleanCodeAttributeCategory(CleanCodeAttributeCategory.Intentional).get(), ).toBeInTheDocument(); expect(ui.ruleCleanCodeAttribute(CleanCodeAttribute.Clear).get()).toBeInTheDocument(); // 1 In Rule details + 1 in facet @@ -627,96 +624,6 @@ describe('Rule app details', () => { expect(ui.tagCheckbox(RULE_TAGS_MOCK[2]).get()).toBeInTheDocument(); expect(ui.tagCheckbox(RULE_TAGS_MOCK[1]).query()).not.toBeInTheDocument(); }); - - describe('custom rule', () => { - it('can create custom rule', async () => { - const { ui, user } = getPageObjects(); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser()); - await ui.appLoaded(); - - await user.click(ui.templateFacet.get()); - await user.click(ui.facetItem('coding_rules.filters.template.is_template').get()); - - // Shows only one template rule - expect(ui.getAllRuleListItems()).toHaveLength(1); - - // Show template rule details - await user.click(ui.ruleListItemLink('Template rule').get()); - expect(ui.ruleTitle('Template rule').get()).toBeInTheDocument(); - expect(ui.customRuleSectionTitle.get()).toBeInTheDocument(); - - // Create custom rule - await user.click(ui.createCustomRuleButton.get()); - await user.type(ui.ruleNameTextbox.get(), 'New Custom Rule'); - expect(ui.keyTextbox.get()).toHaveValue('New_Custom_Rule'); - await user.clear(ui.keyTextbox.get()); - await user.type(ui.keyTextbox.get(), 'new_custom_rule'); - - await selectEvent.select(ui.typeSelect.get(), 'issue.type.BUG'); - await selectEvent.select(ui.oldSeveritySelect.get(), 'severity.MINOR'); - await selectEvent.select(ui.statusSelect.get(), 'rules.status.BETA'); - - await user.type(ui.descriptionTextbox.get(), 'Some description for custom rule'); - await user.type(ui.paramInput('1').get(), 'Default value'); - - await user.click(ui.createButton.get()); - - // Verify the rule is created - expect(ui.customRuleItemLink('New Custom Rule').get()).toBeInTheDocument(); - }); - - it('can edit custom rule', async () => { - const { ui, user } = getPageObjects(); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule9'); - await ui.detailsloaded(); - - await user.click(ui.editCustomRuleButton.get()); - - // Change name and description of custom rule - await user.clear(ui.ruleNameTextbox.get()); - await user.type(ui.ruleNameTextbox.get(), 'Updated custom rule name'); - await user.type(ui.descriptionTextbox.get(), 'Some description for custom rule'); - - await user.click(ui.saveButton.get(ui.updateCustomRuleDialog.get())); - - expect(ui.ruleTitle('Updated custom rule name').get()).toBeInTheDocument(); - }); - - it('can delete custom rule', async () => { - const { ui, user } = getPageObjects(); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule9'); - await ui.detailsloaded(); - - await user.click(ui.deleteButton.get()); - await user.click(ui.deleteButton.get(ui.deleteCustomRuleDialog.get())); - - // Shows the list of rules, custom rule should not be included - expect(ui.ruleListItemLink('Custom Rule based on rule8').query()).not.toBeInTheDocument(); - }); - - it('can delete custom rule from template page', async () => { - const { ui, user } = getPageObjects(); - rulesHandler.setIsAdmin(); - renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8'); - await ui.detailsloaded(); - - await user.click(ui.deleteCustomRuleButton('Custom Rule based on rule8').get()); - await user.click(ui.deleteButton.get(ui.deleteCustomRuleDialog.get())); - expect(ui.customRuleItemLink('Custom Rule based on rule8').query()).not.toBeInTheDocument(); - }); - - it('anonymous user cannot modify custom rule', async () => { - const { ui } = getPageObjects(); - renderCodingRulesApp(undefined, 'coding_rules?open=rule9'); - await ui.appLoaded(); - - expect(ui.editCustomRuleButton.query()).not.toBeInTheDocument(); - expect(ui.deleteButton.query()).not.toBeInTheDocument(); - }); - }); }); describe('redirects', () => { @@ -733,9 +640,9 @@ describe('redirects', () => { renderCodingRulesApp( mockLoggedInUser(), - 'coding_rules#languages=c,js|types=BUG|cleanCodeAttributeCategories=ADAPTABLE', + 'coding_rules#languages=c,js|types=BUG|cleanCodeAttributeCategories=INTENTIONAL', ); - expect(ui.facetItem('issue.clean_code_attribute_category.ADAPTABLE').get()).toBeChecked(); + expect(ui.facetItem('issue.clean_code_attribute_category.INTENTIONAL').get()).toBeChecked(); await user.click(ui.typeFacet.get()); expect(await ui.facetItem(/issue.type.BUG/).find()).toBeChecked(); @@ -744,16 +651,3 @@ describe('redirects', () => { expect(screen.getByText('x_of_y_shown.2.2')).toBeInTheDocument(); }); }); - -function renderCodingRulesApp(currentUser?: CurrentUser, navigateTo?: string) { - renderAppRoutes('coding_rules', routes, { - navigateTo, - currentUser, - languages: { - js: { key: 'js', name: 'JavaScript' }, - java: { key: 'java', name: 'Java' }, - c: { key: 'c', name: 'C' }, - py: { key: 'py', name: 'Python' }, - }, - }); -} diff --git a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CustomRule-it.ts b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CustomRule-it.ts new file mode 100644 index 00000000000..850c4314fed --- /dev/null +++ b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CustomRule-it.ts @@ -0,0 +1,154 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import selectEvent from 'react-select-event'; +import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock'; +import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; +import { mockLoggedInUser } from '../../../helpers/testMocks'; +import { SoftwareQuality } from '../../../types/clean-code-taxonomy'; +import { getPageObjects, renderCodingRulesApp } from '../utils-tests'; + +const rulesHandler = new CodingRulesServiceMock(); +const settingsHandler = new SettingsServiceMock(); + +afterEach(() => { + rulesHandler.reset(); + settingsHandler.reset(); +}); + +describe('custom rule', () => { + it('can create custom rule', async () => { + const { ui, user } = getPageObjects(); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser()); + await ui.appLoaded(); + + await user.click(ui.templateFacet.get()); + await user.click(ui.facetItem('coding_rules.filters.template.is_template').get()); + + // Shows only one template rule + expect(ui.getAllRuleListItems()).toHaveLength(1); + + // Show template rule details + await user.click(ui.ruleListItemLink('Template rule').get()); + expect(ui.ruleTitle('Template rule').get()).toBeInTheDocument(); + expect(ui.customRuleSectionTitle.get()).toBeInTheDocument(); + + // Create custom rule + await user.click(ui.createCustomRuleButton.get()); + await user.type(ui.ruleNameTextbox.get(), 'New Custom Rule'); + expect(ui.keyTextbox.get()).toHaveValue('New_Custom_Rule'); + await user.clear(ui.keyTextbox.get()); + await user.type(ui.keyTextbox.get(), 'new_custom_rule'); + + await selectEvent.select( + ui.cleanCodeCategorySelect.get(), + 'rule.clean_code_attribute_category.CONSISTENT', + ); + await selectEvent.select( + ui.cleanCodeAttributeSelect.get(), + 'rule.clean_code_attribute.IDENTIFIABLE', + ); + + await selectEvent.select( + ui.cleanCodeCategorySelect.get(), + 'rule.clean_code_attribute_category.INTENTIONAL', + ); + // Setting default clean code category of a template should set corresponding attribute + expect( + ui.createCustomRuleDialog.byText('rule.clean_code_attribute.CLEAR').get(), + ).toBeInTheDocument(); + + // Set software qualities + expect(ui.cleanCodeQualityCheckbox(SoftwareQuality.Maintainability).get()).toBeChecked(); + // Uncheck all software qualities - should see error message + await user.click(ui.cleanCodeQualityCheckbox(SoftwareQuality.Maintainability).get()); + expect( + ui.createCustomRuleDialog.byText('coding_rules.custom_rule.select_software_quality').get(), + ).toBeInTheDocument(); + + await user.click(ui.cleanCodeQualityCheckbox(SoftwareQuality.Reliability).get()); + await selectEvent.select( + ui.cleanCodeSeveritySelect(SoftwareQuality.Reliability).get(), + 'severity.MEDIUM', + ); + expect(ui.createCustomRuleDialog.byText('severity.MEDIUM').get()).toBeInTheDocument(); + + await selectEvent.select(ui.statusSelect.get(), 'rules.status.BETA'); + + await user.type(ui.descriptionTextbox.get(), 'Some description for custom rule'); + await user.type(ui.paramInput('1').get(), 'Default value'); + + await user.click(ui.createButton.get()); + + // Verify the rule is created + expect(ui.customRuleItemLink('New Custom Rule').get()).toBeInTheDocument(); + }); + + it('can edit custom rule', async () => { + const { ui, user } = getPageObjects(); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule9'); + await ui.detailsloaded(); + + await user.click(ui.editCustomRuleButton.get()); + + // Change name and description of custom rule + await user.clear(ui.ruleNameTextbox.get()); + await user.type(ui.ruleNameTextbox.get(), 'Updated custom rule name'); + await user.type(ui.descriptionTextbox.get(), 'Some description for custom rule'); + + await user.click(ui.saveButton.get(ui.updateCustomRuleDialog.get())); + + expect(ui.ruleTitle('Updated custom rule name').get()).toBeInTheDocument(); + }); + + it('can delete custom rule', async () => { + const { ui, user } = getPageObjects(); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule9'); + await ui.detailsloaded(); + + await user.click(ui.deleteButton.get()); + await user.click(ui.deleteButton.get(ui.deleteCustomRuleDialog.get())); + + // Shows the list of rules, custom rule should not be included + expect(ui.ruleListItemLink('Custom Rule based on rule8').query()).not.toBeInTheDocument(); + }); + + it('can delete custom rule from template page', async () => { + const { ui, user } = getPageObjects(); + rulesHandler.setIsAdmin(); + renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8'); + await ui.detailsloaded(); + + await user.click(ui.deleteCustomRuleButton('Custom Rule based on rule8').get()); + await user.click(ui.deleteButton.get(ui.deleteCustomRuleDialog.get())); + expect(ui.customRuleItemLink('Custom Rule based on rule8').query()).not.toBeInTheDocument(); + }); + + it('anonymous user cannot modify custom rule', async () => { + const { ui } = getPageObjects(); + renderCodingRulesApp(undefined, 'coding_rules?open=rule9'); + await ui.appLoaded(); + + expect(ui.editCustomRuleButton.query()).not.toBeInTheDocument(); + expect(ui.deleteButton.query()).not.toBeInTheDocument(); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormFieldsCCT.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormFieldsCCT.tsx new file mode 100644 index 00000000000..63be3ec9962 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormFieldsCCT.tsx @@ -0,0 +1,223 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { + Checkbox, + FormField, + Highlight, + InputSelect, + LightPrimary, + RequiredIcon, + TextError, +} from 'design-system'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import SoftwareImpactSeverityIcon from '../../../components/icons/SoftwareImpactSeverityIcon'; +import { + CLEAN_CODE_ATTRIBUTES_BY_CATEGORY, + CLEAN_CODE_CATEGORIES, + IMPACT_SEVERITIES, + SOFTWARE_QUALITIES, +} from '../../../helpers/constants'; +import { + CleanCodeAttribute, + CleanCodeAttributeCategory, + SoftwareImpact, + SoftwareImpactSeverity, + SoftwareQuality, +} from '../../../types/clean-code-taxonomy'; + +interface Props { + value: T; + onChange: (value: T) => void; + disabled?: boolean; +} + +export function CleanCodeCategoryField(props: Readonly>) { + const { value, disabled } = props; + const intl = useIntl(); + + const categories = CLEAN_CODE_CATEGORIES.map((category) => ({ + value: category, + label: intl.formatMessage({ id: `rule.clean_code_attribute_category.${category}` }), + })); + + return ( + + props.onChange(option?.value as CleanCodeAttributeCategory)} + isClearable={false} + isDisabled={disabled} + isSearchable={false} + value={categories.find((category) => category.value === value)} + /> + + ); +} + +export function CleanCodeAttributeField( + props: Readonly & { category: CleanCodeAttributeCategory }>, +) { + const { value, disabled, category, onChange } = props; + const initialAttribute = React.useRef(value); + const intl = useIntl(); + + const attributes = CLEAN_CODE_ATTRIBUTES_BY_CATEGORY[category].map((attribute) => ({ + value: attribute, + label: intl.formatMessage({ id: `rule.clean_code_attribute.${attribute}` }), + })); + + // Set default CC attribute when category changes + React.useEffect(() => { + if (CLEAN_CODE_ATTRIBUTES_BY_CATEGORY[category].includes(value)) { + return; + } + const initialAttributeIndex = CLEAN_CODE_ATTRIBUTES_BY_CATEGORY[category].findIndex( + (attribute) => attribute === initialAttribute.current, + ); + onChange( + CLEAN_CODE_ATTRIBUTES_BY_CATEGORY[category][ + initialAttributeIndex === -1 ? 0 : initialAttributeIndex + ], + ); + }, [onChange, category, value]); + + return ( + + props.onChange(option?.value as CleanCodeAttribute)} + isClearable={false} + isDisabled={disabled} + isSearchable={false} + value={attributes.find((attribute) => attribute.value === value)} + /> + + ); +} + +export function SoftwareQualitiesFields( + props: Readonly & { error: boolean }>, +) { + const { value, disabled, error } = props; + const intl = useIntl(); + + const severities = React.useMemo( + () => + IMPACT_SEVERITIES.map((severity) => ({ + value: severity, + label: intl.formatMessage({ id: `severity.${severity}` }), + Icon: , + })), + [intl], + ); + + const handleSoftwareQualityChange = (quality: SoftwareQuality, checked: boolean) => { + if (checked) { + props.onChange([ + ...value, + { softwareQuality: quality, severity: SoftwareImpactSeverity.Low }, + ]); + } else { + props.onChange(value.filter((impact) => impact.softwareQuality !== quality)); + } + }; + + const handleSeverityChange = (quality: SoftwareQuality, severity: SoftwareImpactSeverity) => { + props.onChange( + value.map((impact) => + impact.softwareQuality === quality ? { ...impact, severity } : impact, + ), + ); + }; + + return ( +
+ + + {intl.formatMessage({ id: 'software_quality' })} + + + + {intl.formatMessage({ id: 'severity' })} + + + + {SOFTWARE_QUALITIES.map((quality) => { + const selectedQuality = value.find((impact) => impact.softwareQuality === quality); + const selectedSeverity = selectedQuality + ? severities.find((severity) => severity.value === selectedQuality.severity) + : null; + + return ( +
+ + {intl.formatMessage( + { id: 'coding_rules.custom_rule.software_quality_x' }, + { quality }, + )} + + { + handleSoftwareQualityChange(quality, checked); + }} + label={quality} + > + + {intl.formatMessage({ id: `software_quality.${quality}` })} + + + + handleSeverityChange(quality, option?.value as SoftwareImpactSeverity) + } + isClearable={false} + isDisabled={disabled || !selectedQuality} + isSearchable={false} + value={selectedSeverity} + /> +
+ ); + })} + {error && ( + + )} +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormModal.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormModal.tsx index a8c41fc8fd6..de13a247820 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormModal.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormModal.tsx @@ -30,18 +30,25 @@ import { Modal, } from 'design-system'; import * as React from 'react'; -import { OptionProps, SingleValueProps, components } from 'react-select'; import FormattingTips from '../../../components/common/FormattingTips'; -import TypeHelper from '../../../components/shared/TypeHelper'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; -import { RULE_STATUSES, RULE_TYPES } from '../../../helpers/constants'; +import { RULE_STATUSES } from '../../../helpers/constants'; import { csvEscape } from '../../../helpers/csv'; import { translate } from '../../../helpers/l10n'; import { sanitizeString } from '../../../helpers/sanitize'; import { latinize } from '../../../helpers/strings'; import { useCreateRuleMutation, useUpdateRuleMutation } from '../../../queries/rules'; -import { Dict, RuleDetails, RuleParameter, RuleType, Status } from '../../../types/types'; -import { SeveritySelect } from './SeveritySelect'; +import { + CleanCodeAttribute, + CleanCodeAttributeCategory, + SoftwareImpact, +} from '../../../types/clean-code-taxonomy'; +import { Dict, RuleDetails, RuleParameter, Status } from '../../../types/types'; +import { + CleanCodeAttributeField, + CleanCodeCategoryField, + SoftwareQualitiesFields, +} from './CustomRuleFormFieldsCCT'; interface Props { customRule?: RuleDetails; @@ -59,9 +66,14 @@ export default function CustomRuleFormModal(props: Readonly) { const [name, setName] = React.useState(customRule?.name ?? ''); const [params, setParams] = React.useState(getParams(customRule)); const [reactivating, setReactivating] = React.useState(false); - const [severity, setSeverity] = React.useState(customRule?.severity ?? templateRule.severity); const [status, setStatus] = React.useState(customRule?.status ?? templateRule.status); - const [type, setType] = React.useState(customRule?.type ?? templateRule.type); + const [ccCategory, setCCCategory] = React.useState( + templateRule.cleanCodeAttributeCategory ?? CleanCodeAttributeCategory.Consistent, + ); + const [ccAttribute, setCCAtribute] = React.useState( + templateRule.cleanCodeAttribute ?? CleanCodeAttribute.Conventional, + ); + const [impacts, setImpacts] = React.useState(templateRule?.impacts ?? []); const { mutate: updateRule, isLoading: updatingRule } = useUpdateRuleMutation(props.onClose); const { mutate: createRule, isLoading: creatingRule } = useCreateRuleMutation( { @@ -75,6 +87,7 @@ export default function CustomRuleFormModal(props: Readonly) { ); const submitting = updatingRule || creatingRule; + const hasError = impacts.length === 0; const submit = () => { const stringifiedParams = Object.keys(params) @@ -84,7 +97,6 @@ export default function CustomRuleFormModal(props: Readonly) { markdownDescription: description, name, params: stringifiedParams, - severity, status, }; return customRule @@ -94,7 +106,8 @@ export default function CustomRuleFormModal(props: Readonly) { customKey: key, preventReactivation: !reactivating, templateKey: templateRule.key, - type, + cleanCodeAttribute: ccAttribute, + impacts, }); }; @@ -180,51 +193,6 @@ export default function CustomRuleFormModal(props: Readonly) { [description, submitting], ); - const TypeField = React.useMemo(() => { - const ruleTypeOption: LabelValueSelectOption[] = RULE_TYPES.map((type) => ({ - label: translate('issue.type', type), - value: type, - })); - return ( - - ) => setType(value)} - components={{ - Option: TypeSelectOption, - SingleValue: TypeSelectValue, - }} - options={ruleTypeOption} - value={ruleTypeOption.find((t) => t.value === type)} - /> - - ); - }, [type, submitting]); - - const SeverityField = React.useMemo( - () => ( - - setSeverity(value)} - severity={severity} - /> - - ), - [severity, submitting], - ); - const StatusField = React.useMemo(() => { const statusesOptions = RULE_STATUSES.map((status) => ({ label: translate('rules.status', status), @@ -339,16 +307,33 @@ export default function CustomRuleFormModal(props: Readonly) { {NameField} {KeyField} - {/* do not allow to change the type of existing rule */} - {!customRule && TypeField} - {SeverityField} + +
+ + +
+ {StatusField} {DescriptionField} {templateParams.map(renderParameterField)} } primaryButton={ - + {buttonText} } @@ -358,26 +343,6 @@ export default function CustomRuleFormModal(props: Readonly) { ); } -function TypeSelectOption( - optionProps: Readonly, false>>, -) { - return ( - - - - ); -} - -function TypeSelectValue( - valueProps: Readonly, false>>, -) { - return ( - - - - ); -} - function getParams(customRule?: RuleDetails) { const params: Dict = {}; 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 734c2155efc..1bb89ffafb7 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 @@ -20,6 +20,7 @@ import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Profile } from '../../api/quality-profiles'; +import { renderAppRoutes } from '../../helpers/testReactTestingUtils'; import { byLabelText, byPlaceholderText, @@ -32,6 +33,8 @@ import { CleanCodeAttributeCategory, SoftwareQuality, } from '../../types/clean-code-taxonomy'; +import { CurrentUser } from '../../types/users'; +import routes from './routes'; const selectors = { loading: byLabelText('loading'), @@ -174,7 +177,17 @@ const selectors = { deleteCustomRuleDialog: byRole('dialog', { name: 'coding_rules.delete_rule' }), ruleNameTextbox: byRole('textbox', { name: 'name' }), keyTextbox: byRole('textbox', { name: 'key' }), - typeSelect: byRole('combobox', { name: 'type' }), + cleanCodeCategorySelect: byRole('combobox', { name: 'category' }), + cleanCodeAttributeSelect: byRole('combobox', { name: 'attribute' }), + cleanCodeQualityCheckbox: (quality: SoftwareQuality) => + byRole('group', { name: `coding_rules.custom_rule.software_quality_x.${quality}` }).byRole( + 'checkbox', + ), + cleanCodeSeveritySelect: (quality: SoftwareQuality) => + byRole('group', { name: `coding_rules.custom_rule.software_quality_x.${quality}` }).byRole( + 'combobox', + { name: 'severity' }, + ), statusSelect: byRole('combobox', { name: 'coding_rules.filters.status' }), descriptionTextbox: byRole('textbox', { name: 'description' }), createButton: byRole('button', { name: 'create' }), @@ -229,3 +242,16 @@ export function getPageObjects() { user, }; } + +export function renderCodingRulesApp(currentUser?: CurrentUser, navigateTo?: string) { + renderAppRoutes('coding_rules', routes, { + navigateTo, + currentUser, + languages: { + js: { key: 'js', name: 'JavaScript' }, + java: { key: 'java', name: 'Java' }, + c: { key: 'c', name: 'C' }, + py: { key: 'py', name: 'Python' }, + }, + }); +} diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx index 6df26597fed..6bc9ba2d3cd 100644 --- a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx +++ b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx @@ -104,9 +104,9 @@ export default function ApiSidebar({ apisList, docInfo }: Readonly) { value={search} /> -
+
setShowInternal((prev) => !prev)}> - {translate('api_documentation.show_internal')} + {translate('api_documentation.show_internal')} diff --git a/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx b/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx index de84d2d5437..56cbea01a84 100644 --- a/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx +++ b/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx @@ -20,16 +20,15 @@ import classNames from 'classnames'; import React from 'react'; import { translate } from '../../helpers/l10n'; -import { SoftwareImpactSeverity, SoftwareQuality } from '../../types/clean-code-taxonomy'; +import { + SoftwareImpact, + SoftwareImpactSeverity, + SoftwareQuality, +} from '../../types/clean-code-taxonomy'; import SoftwareImpactPill from './SoftwareImpactPill'; -interface SoftwareImpact { - softwareQuality: SoftwareQuality; - severity: SoftwareImpactSeverity; -} - interface SoftwareImpactPillListProps extends React.HTMLAttributes { - softwareImpacts: Array; + softwareImpacts: SoftwareImpact[]; className?: string; type?: Parameters[0]['type']; } diff --git a/server/sonar-web/src/main/js/helpers/constants.ts b/server/sonar-web/src/main/js/helpers/constants.ts index ec5a1800c61..0c0806f8403 100644 --- a/server/sonar-web/src/main/js/helpers/constants.ts +++ b/server/sonar-web/src/main/js/helpers/constants.ts @@ -20,6 +20,7 @@ import { colors } from '../app/theme'; import { AlmKeys } from '../types/alm-settings'; import { + CleanCodeAttribute, CleanCodeAttributeCategory, SoftwareImpactSeverity, SoftwareQuality, @@ -41,6 +42,31 @@ export const IMPACT_SEVERITIES = Object.values(SoftwareImpactSeverity); export const CLEAN_CODE_CATEGORIES = Object.values(CleanCodeAttributeCategory); +export const CLEAN_CODE_ATTRIBUTES_BY_CATEGORY = { + [CleanCodeAttributeCategory.Consistent]: [ + CleanCodeAttribute.Conventional, + CleanCodeAttribute.Identifiable, + CleanCodeAttribute.Formatted, + ], + [CleanCodeAttributeCategory.Intentional]: [ + CleanCodeAttribute.Logical, + CleanCodeAttribute.Clear, + CleanCodeAttribute.Complete, + CleanCodeAttribute.Efficient, + ], + [CleanCodeAttributeCategory.Adaptable]: [ + CleanCodeAttribute.Focused, + CleanCodeAttribute.Distinct, + CleanCodeAttribute.Modular, + CleanCodeAttribute.Tested, + ], + [CleanCodeAttributeCategory.Responsible]: [ + CleanCodeAttribute.Trustworthy, + CleanCodeAttribute.Lawful, + CleanCodeAttribute.Respectful, + ], +}; + export const SOFTWARE_QUALITIES = Object.values(SoftwareQuality); export const STATUSES = ['OPEN', 'CONFIRMED', 'REOPENED', 'RESOLVED', 'CLOSED']; diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index e857c060594..acd56af28be 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -623,7 +623,7 @@ export function mockRuleActivation(overrides: Partial = {}): Rul export function mockRuleDetails(overrides: Partial = {}): RuleDetails { return { - cleanCodeAttributeCategory: CleanCodeAttributeCategory.Adaptable, + cleanCodeAttributeCategory: CleanCodeAttributeCategory.Intentional, cleanCodeAttribute: CleanCodeAttribute.Clear, key: 'squid:S1337', repo: 'squid', diff --git a/server/sonar-web/src/main/js/types/clean-code-taxonomy.ts b/server/sonar-web/src/main/js/types/clean-code-taxonomy.ts index 80e944c5e1e..28816bacad1 100644 --- a/server/sonar-web/src/main/js/types/clean-code-taxonomy.ts +++ b/server/sonar-web/src/main/js/types/clean-code-taxonomy.ts @@ -53,3 +53,8 @@ export enum SoftwareQuality { Reliability = 'RELIABILITY', Maintainability = 'MAINTAINABILITY', } + +export interface SoftwareImpact { + softwareQuality: SoftwareQuality; + severity: SoftwareImpactSeverity; +} diff --git a/server/sonar-web/src/main/js/types/issues.ts b/server/sonar-web/src/main/js/types/issues.ts index 223164569e9..a0865352dae 100644 --- a/server/sonar-web/src/main/js/types/issues.ts +++ b/server/sonar-web/src/main/js/types/issues.ts @@ -20,8 +20,7 @@ import { CleanCodeAttribute, CleanCodeAttributeCategory, - SoftwareImpactSeverity, - SoftwareQuality, + SoftwareImpact, } from './clean-code-taxonomy'; import { Issue, Paging, TextRange } from './types'; import { UserBase } from './users'; @@ -127,10 +126,7 @@ export interface RawIssue { author?: string; cleanCodeAttributeCategory: CleanCodeAttributeCategory; cleanCodeAttribute: CleanCodeAttribute; - impacts: Array<{ - softwareQuality: SoftwareQuality; - severity: SoftwareImpactSeverity; - }>; + impacts: SoftwareImpact[]; codeVariants?: string[]; comments?: Comment[]; creationDate: string; diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts index 5dad670348d..9a11700a27a 100644 --- a/server/sonar-web/src/main/js/types/types.ts +++ b/server/sonar-web/src/main/js/types/types.ts @@ -21,8 +21,7 @@ import { RuleDescriptionSection } from '../apps/coding-rules/rule'; import { CleanCodeAttribute, CleanCodeAttributeCategory, - SoftwareImpactSeverity, - SoftwareQuality, + SoftwareImpact, } from './clean-code-taxonomy'; import { ComponentQualifier, Visibility } from './component'; import { IssueStatus, IssueTransition, MessageFormatting } from './issues'; @@ -262,10 +261,7 @@ export interface Issue { branch?: string; cleanCodeAttributeCategory: CleanCodeAttributeCategory; cleanCodeAttribute: CleanCodeAttribute; - impacts: Array<{ - softwareQuality: SoftwareQuality; - severity: SoftwareImpactSeverity; - }>; + impacts: SoftwareImpact[]; codeVariants?: string[]; comments?: IssueComment[]; component: string; @@ -544,10 +540,7 @@ export type RawQuery = Dict; export interface Rule { cleanCodeAttributeCategory?: CleanCodeAttributeCategory; cleanCodeAttribute?: CleanCodeAttribute; - impacts: Array<{ - softwareQuality: SoftwareQuality; - severity: SoftwareImpactSeverity; - }>; + impacts: SoftwareImpact[]; isTemplate?: boolean; key: string; lang?: string; @@ -578,7 +571,6 @@ export interface RulesUpdateRequest { remediation_fn_base_effort?: string; remediation_fn_type?: string; remediation_fy_gap_multiplier?: string; - severity?: string; status?: string; tags?: string; } diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index a03c68331d3..a6c112d939b 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -36,6 +36,7 @@ by_=by calendar=Calendar cancel=Cancel category=Category +attribute=Attribute see_changelog=See Changelog changelog=Changelog change_verb=Change @@ -2319,6 +2320,8 @@ coding_rules.create_custom_rule=Create Custom Rule coding_rules.custom_rule=Custom Rule coding_rules.custom_rule.help=Custom rules are created by administrators from templates, and are the only fully-editable rules. coding_rules.custom_rule.activation_notice=Note: parameters of a custom rule are not customizable on rule activation, only during creation/edit. +coding_rules.custom_rule.software_quality_x={quality} software quality +coding_rules.custom_rule.select_software_quality=Please select at least one software quality. coding_rules.custom_rule.removal=Only custom rules may be deleted. When a custom rule is deleted, it is not removed from the SonarQube instance. Instead its status is set to "REMOVED", allowing relevant issues to continue to be displayed properly. coding_rules.custom_rules=Custom Rules coding_rules.deactivate_in_quality_profile=Deactivate In Quality Profile -- 2.39.5