From: stanislavh Date: Fri, 1 Dec 2023 13:02:46 +0000 (+0100) Subject: SONAR-21131 Update rules app with react query X-Git-Tag: 10.4.0.87286~370 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=810c180f2acc8cc04431140cb6ae57003df06435;p=sonarqube.git SONAR-21131 Update rules app with react query --- 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 d7f9b6020bd..28bbeb45838 100644 --- a/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts @@ -359,7 +359,7 @@ export default class CodingRulesServiceMock { 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; + rule.tags = data.tags !== undefined ? data.tags.split(',') : rule.tags; return this.reply(rule); }; diff --git a/server/sonar-web/src/main/js/api/quality-profiles.ts b/server/sonar-web/src/main/js/api/quality-profiles.ts index b58a77561fe..8f10efc1fcc 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -323,18 +323,25 @@ export function bulkDeactivateRules(data: BulkActivateParameters) { return postJSON('/api/qualityprofiles/deactivate_rules', data); } -export function activateRule(data: { +export interface ActivateRuleParameters { key: string; params?: Dict; reset?: boolean; rule: string; severity?: string; -}) { +} + +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); } -export function deactivateRule(data: { key: string; rule: string }) { +export interface DeactivateRuleParameters { + key: string; + rule: string; +} + +export function deactivateRule(data: DeactivateRuleParameters) { return post('/api/qualityprofiles/deactivate_rule', data).catch(throwGlobalError); } 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 b80658392a7..a6497ce29e7 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 @@ -380,7 +380,7 @@ describe('Rule app details', () => { it('shows rule with default description section and params', async () => { const { ui } = getPageObjects(); renderCodingRulesApp(undefined, 'coding_rules?open=rule1'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.ruleTitle('Awsome java rule').get()).toBeInTheDocument(); expect( ui.ruleCleanCodeAttributeCategory(CleanCodeAttributeCategory.Adaptable).get(), @@ -400,7 +400,7 @@ describe('Rule app details', () => { it('shows external rule', async () => { const { ui } = getPageObjects(); renderCodingRulesApp(undefined, 'coding_rules?open=rule6'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.ruleTitle('Bad Python rule').get()).toBeInTheDocument(); expect(ui.externalDescription('Bad Python rule').get()).toBeInTheDocument(); }); @@ -408,7 +408,7 @@ describe('Rule app details', () => { it('shows hotspot rule', async () => { const { ui, user } = getPageObjects(); renderCodingRulesApp(undefined, 'coding_rules?open=rule2'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.ruleTitle('Hot hotspot').get()).toBeInTheDocument(); expect(ui.introTitle.get()).toBeInTheDocument(); @@ -424,7 +424,7 @@ describe('Rule app details', () => { it('shows rule advanced section', async () => { const { ui } = getPageObjects(); renderCodingRulesApp(undefined, 'coding_rules?open=rule5'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.ruleTitle('Awsome Python rule').get()).toBeInTheDocument(); expect(ui.introTitle.get()).toBeInTheDocument(); // Shows correct tabs @@ -436,7 +436,7 @@ describe('Rule app details', () => { it('shows rule advanced section with context', async () => { const { ui, user } = getPageObjects(); renderCodingRulesApp(undefined, 'coding_rules?open=rule7'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.ruleTitle('Python rule with context').get()).toBeInTheDocument(); await user.click(ui.howToFixTab.get()); @@ -455,7 +455,7 @@ describe('Rule app details', () => { it('should show CYAC notification for rule advanced section and removes it after user`s visit', async () => { const { ui, user } = getPageObjects(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule10'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.moreInfoTab.get()); expect(ui.caycNotificationButton.get()).toBeInTheDocument(); @@ -470,7 +470,7 @@ describe('Rule app details', () => { it('should show CAYC notification for rule advanced section and removes it when user scrolls to the principles', async () => { const { ui, user } = getPageObjects(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule10'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.moreInfoTab.get()); expect(ui.caycNotificationButton.get()).toBeInTheDocument(); @@ -487,7 +487,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); renderCodingRulesApp(mockCurrentUser(), 'coding_rules?open=rule10'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.moreInfoTab.get()); expect(ui.caycNotificationButton.query()).not.toBeInTheDocument(); @@ -498,7 +498,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule1'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.qpLink('QP Foo').get()).toBeInTheDocument(); // Activate rule in quality profile @@ -542,7 +542,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule1'); - await ui.appLoaded(); + await ui.detailsloaded(); // Should show 2 deactivate buttons: one for the parent, one for the child profile. expect(ui.deactivateInQPButton('QP FooBarBaz').get()).toBeInTheDocument(); @@ -559,7 +559,7 @@ describe('Rule app details', () => { rulesHandler.setIsAdmin(); settingsHandler.set(SettingsKey.QPAdminCanDisableInheritedRules, 'false'); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule1'); - await ui.appLoaded(); + await ui.detailsloaded(); // Should show 1 deactivate button: one for the parent, none for the child profile. expect(ui.deactivateInQPButton('QP FooBarBaz').get()).toBeInTheDocument(); @@ -570,7 +570,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(undefined, 'coding_rules?open=rule5'); - await ui.appLoaded(); + await ui.detailsloaded(); expect(ui.ruleTitle('Awsome Python rule').get()).toBeInTheDocument(); // Add @@ -603,7 +603,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(undefined, 'coding_rules?open=rule10'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.tagsDropdown.get()); @@ -670,7 +670,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule9'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.editCustomRuleButton.get()); @@ -688,7 +688,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule9'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.deleteButton.get()); await user.click(ui.deleteButton.get(ui.deleteCustomRuleDialog.get())); @@ -701,7 +701,7 @@ describe('Rule app details', () => { const { ui, user } = getPageObjects(); rulesHandler.setIsAdmin(); renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8'); - await ui.appLoaded(); + await ui.detailsloaded(); await user.click(ui.deleteCustomRuleButton('Custom Rule based on rule8').get()); await user.click(ui.deleteButton.get(ui.deleteCustomRuleDialog.get())); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationButton.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationButton.tsx index 24aa404d821..b2610f5aa8c 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationButton.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/ActivationButton.tsx @@ -28,7 +28,7 @@ interface Props { buttonText: string; className?: string; modalHeader: string; - onDone: (severity: string) => Promise; + onDone?: (severity: string) => Promise | void; profiles: BaseProfile[]; rule: Rule | RuleDetails; ariaLabel?: string; 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 f1edb4ec687..01771fdf72a 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 @@ -29,10 +29,11 @@ import { Note, } from 'design-system'; import * as React from 'react'; -import { Profile, activateRule } from '../../../api/quality-profiles'; +import { Profile } from '../../../api/quality-profiles'; import DocLink from '../../../components/common/DocLink'; import { translate } from '../../../helpers/l10n'; import { sanitizeString } from '../../../helpers/sanitize'; +import { useActivateRuleMutation } from '../../../queries/quality-profiles'; import { IssueSeverity } from '../../../types/issues'; import { Dict, Rule, RuleActivation, RuleDetails } from '../../../types/types'; import { sortProfiles } from '../../quality-profiles/utils'; @@ -42,8 +43,7 @@ interface Props { activation?: RuleActivation; modalHeader: string; onClose: () => void; - onDone: (severity: string) => Promise; - // eslint-disable-next-line react/no-unused-prop-types + onDone?: (severity: string) => Promise | void; profiles: Profile[]; rule: Rule | RuleDetails; } @@ -52,222 +52,190 @@ interface ProfileWithDepth extends Profile { depth: number; } -interface State { - params: Dict; - profile?: ProfileWithDepth; - submitting: boolean; - severity: IssueSeverity; -} - const MIN_PROFILES_TO_ENABLE_SELECT = 2; const FORM_ID = 'rule-activation-modal-form'; -export default class ActivationFormModal extends React.PureComponent { - mounted = false; - - constructor(props: Props) { - super(props); - const profilesWithDepth = this.getQualityProfilesWithDepth(props); - this.state = { - params: this.getParams(props), - profile: profilesWithDepth.length > 0 ? profilesWithDepth[0] : undefined, - submitting: false, - severity: (props.activation - ? props.activation.severity - : props.rule.severity) as IssueSeverity, - }; - } - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - getParams = ({ activation, rule } = this.props) => { - const params: Dict = {}; - if (rule?.params) { - for (const param of rule.params) { - params[param.key] = param.defaultValue || ''; - } - if (activation?.params) { - for (const param of activation.params) { - params[param.key] = param.value; - } - } - } - return params; - }; - - // Choose QP which a user can administrate, which are the same language and which are not built-in - getQualityProfilesWithDepth = ({ profiles } = this.props) => { - return sortProfiles( - profiles.filter( - (profile) => - !profile.isBuiltIn && - profile.actions && - profile.actions.edit && - profile.language === this.props.rule.lang, - ), - ).map((profile) => ({ - ...profile, - // Decrease depth by 1, so the top level starts at 0 - depth: profile.depth - 1, - })); - }; - - handleFormSubmit = (event: React.SyntheticEvent) => { +export default function ActivationFormModal(props: Readonly) { + const { activation, rule, profiles, modalHeader } = props; + const { mutate: activateRule, isLoading: submitting } = useActivateRuleMutation((data) => { + props.onDone?.(data.severity as string); + props.onClose(); + }); + + const profilesWithDepth = getQualityProfilesWithDepth(profiles, rule.lang); + const [profile, setProfile] = React.useState(profilesWithDepth[0]); + const [params, setParams] = React.useState(getRuleParams({ activation, rule })); + const [severity, setSeverity] = React.useState( + (activation ? activation.severity : rule.severity) as IssueSeverity, + ); + + const profileOptions = profilesWithDepth.map((p) => ({ label: p.name, value: p })); + const isCustomRule = !!(rule as RuleDetails).templateKey; + const activeInAllProfiles = profilesWithDepth.length <= 0; + const isUpdateMode = !!activation; + + const handleFormSubmit = (event: React.SyntheticEvent) => { event.preventDefault(); - this.setState({ submitting: true }); const data = { - key: this.state.profile?.key ?? '', - params: this.state.params, - rule: this.props.rule.key, - severity: this.state.severity, + key: profile?.key ?? '', + params, + rule: rule.key, + severity, }; - activateRule(data) - .then(() => this.props.onDone(data.severity)) - .then( - () => { - if (this.mounted) { - this.setState({ submitting: false }); - this.props.onClose(); - } - }, - () => { - if (this.mounted) { - this.setState({ submitting: false }); - } - }, - ); + activateRule(data); }; - handleParameterChange = (event: React.SyntheticEvent) => { + const handleParameterChange = ( + event: React.SyntheticEvent, + ) => { const { name, value } = event.currentTarget; - this.setState((state: State) => ({ params: { ...state.params, [name]: value } })); - }; - - handleProfileChange = (value: LabelValueSelectOption) => { - this.setState({ profile: value.value }); - }; - - handleSeverityChange = ({ value }: LabelValueSelectOption) => { - this.setState({ severity: value }); + setParams({ ...params, [name]: value }); }; - render() { - const { activation, rule } = this.props; - const { profile, severity, submitting } = this.state; - const { params = [] } = rule; - const profilesWithDepth = this.getQualityProfilesWithDepth(); - const profileOptions = profilesWithDepth.map((p) => ({ label: p.name, value: p })); - const isCustomRule = !!(rule as RuleDetails).templateKey; - const activeInAllProfiles = profilesWithDepth.length <= 0; - const isUpdateMode = !!activation; - - return ( - - {isUpdateMode ? translate('save') : translate('coding_rules.activate')} - - } - secondaryButtonLabel={translate('cancel')} - body={ -
- {!isUpdateMode && activeInAllProfiles && ( - - {translate('coding_rules.active_in_all_profiles')} - - )} - - - {translate('coding_rules.severity_deprecated')} - - {translate('learn_more')} - + return ( + + {isUpdateMode ? translate('save') : translate('coding_rules.activate')} + + } + secondaryButtonLabel={translate('cancel')} + body={ + + {!isUpdateMode && activeInAllProfiles && ( + + {translate('coding_rules.active_in_all_profiles')} + )} + + + {translate('coding_rules.severity_deprecated')} + + {translate('learn_more')} + + + + + ) => { + setProfile(value); + }} + getOptionLabel={({ value }: LabelValueSelectOption) => + ' '.repeat(value.depth) + value.name + } + options={profileOptions} + value={profileOptions.find(({ value }) => value.key === profile?.key)} + /> + + + + ) => { + setSeverity(value); + }} + severity={severity} + /> + + + {isCustomRule ? ( + + {translate('coding_rules.custom_rule.activation_notice')} + + ) : ( + rule.params?.map((param) => ( + + {param.type === 'TEXT' ? ( + + ) : ( + + )} + {param.htmlDesc !== undefined && ( + + )} + + )) + )} + + } + /> + ); +} - - ) => - ' '.repeat(value.depth) + value.name - } - options={profileOptions} - value={profileOptions.find(({ value }) => value.key === profile?.key)} - /> - - - - - +function getQualityProfilesWithDepth( + profiles: Profile[] = [], + ruleLang?: string, +): ProfileWithDepth[] { + return sortProfiles( + profiles.filter( + (profile) => + !profile.isBuiltIn && + profile.actions && + profile.actions.edit && + profile.language === ruleLang, + ), + ).map((profile) => ({ + ...profile, + // Decrease depth by 1, so the top level starts at 0 + depth: profile.depth - 1, + })); +} - {isCustomRule ? ( - - {translate('coding_rules.custom_rule.activation_notice')} - - ) : ( - params.map((param) => ( - - {param.type === 'TEXT' ? ( - - ) : ( - - )} - {param.htmlDesc !== undefined && ( - - )} - - )) - )} - - } - /> - ); +function getRuleParams({ + activation, + rule, +}: { + activation?: RuleActivation; + rule: RuleDetails | Rule; +}) { + const params: Dict = {}; + if (rule?.params) { + for (const param of rule.params) { + params[param.key] = param.defaultValue ?? ''; + } + if (activation?.params) { + for (const param of activation.params) { + params[param.key] = param.value; + } + } } + return params; } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleButton.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleButton.tsx index 229d8dfb3be..c11f794e06d 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleButton.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleButton.tsx @@ -24,7 +24,6 @@ import CustomRuleFormModal from './CustomRuleFormModal'; interface Props { children: (props: { onClick: () => void }) => React.ReactNode; customRule?: RuleDetails; - onDone: (newRuleDetails: RuleDetails) => void; templateRule: RuleDetails; } @@ -32,11 +31,6 @@ export default function CustomRuleButton(props: Props) { const { customRule, templateRule } = props; const [modalOpen, setModalOpen] = React.useState(false); - const handleDone = (newRuleDetails: RuleDetails) => { - setModalOpen(false); - props.onDone(newRuleDetails); - }; - return ( <> {props.children({ onClick: () => setModalOpen(true) })} @@ -44,7 +38,6 @@ export default function CustomRuleButton(props: Props) { setModalOpen(false)} - onDone={handleDone} templateRule={templateRule} /> )} 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 a241a4b187e..a8c41fc8fd6 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 @@ -31,7 +31,6 @@ import { } from 'design-system'; import * as React from 'react'; import { OptionProps, SingleValueProps, components } from 'react-select'; -import { createRule, updateRule } from '../../../api/rules'; import FormattingTips from '../../../components/common/FormattingTips'; import TypeHelper from '../../../components/shared/TypeHelper'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; @@ -40,215 +39,148 @@ 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'; interface Props { customRule?: RuleDetails; onClose: () => void; - onDone: (newRuleDetails: RuleDetails) => void; templateRule: RuleDetails; } -interface State { - description: string; - key: string; - keyModifiedByUser: boolean; - name: string; - params: Dict; - reactivating: boolean; - severity: string; - status: string; - submitting: boolean; - type: RuleType; -} - const FORM_ID = 'custom-rule-form'; -export default class CustomRuleFormModal extends React.PureComponent { - mounted = false; - - constructor(props: Props) { - super(props); - const params: Dict = {}; - if (props.customRule?.params) { - for (const param of props.customRule.params) { - params[param.key] = param.defaultValue ?? ''; - } - } - this.state = { - description: props.customRule?.mdDesc ?? '', - key: '', - keyModifiedByUser: false, - name: props.customRule?.name ?? '', - params, - reactivating: false, - severity: props.customRule?.severity ?? props.templateRule.severity, - status: props.customRule?.status ?? props.templateRule.status, - submitting: false, - type: props.customRule?.type ?? props.templateRule.type, - }; - } - - componentDidMount() { - this.mounted = true; - } +export default function CustomRuleFormModal(props: Readonly) { + const { customRule, templateRule } = props; + const [description, setDescription] = React.useState(customRule?.mdDesc ?? ''); + const [key, setKey] = React.useState(customRule?.key ?? ''); + const [keyModifiedByUser, setKeyModifiedByUser] = React.useState(false); + 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 { mutate: updateRule, isLoading: updatingRule } = useUpdateRuleMutation(props.onClose); + const { mutate: createRule, isLoading: creatingRule } = useCreateRuleMutation( + { + f: 'name,severity,params', + template_key: templateRule.key, + }, + props.onClose, + (response: Response) => { + setReactivating(response.status === HttpStatusCode.Conflict); + }, + ); - componentWillUnmount() { - this.mounted = false; - } + const submitting = updatingRule || creatingRule; - prepareRequest = () => { - const { customRule, templateRule } = this.props; - const params = Object.keys(this.state.params) - .map((key) => `${key}=${csvEscape(this.state.params[key])}`) + const submit = () => { + const stringifiedParams = Object.keys(params) + .map((key) => `${key}=${csvEscape(params[key])}`) .join(';'); const ruleData = { - markdownDescription: this.state.description, - name: this.state.name, - params, - severity: this.state.severity, - status: this.state.status, + markdownDescription: description, + name, + params: stringifiedParams, + severity, + status, }; return customRule ? updateRule({ ...ruleData, key: customRule.key }) : createRule({ ...ruleData, - customKey: this.state.key, - preventReactivation: !this.state.reactivating, + customKey: key, + preventReactivation: !reactivating, templateKey: templateRule.key, - type: this.state.type, + type, }); }; - handleFormSubmit = (event: React.SyntheticEvent) => { - event.preventDefault(); - this.setState({ submitting: true }); - this.prepareRequest().then( - (newRuleDetails) => { - if (this.mounted) { - this.setState({ submitting: false }); - this.props.onDone(newRuleDetails); - } - }, - (response: Response) => { - if (this.mounted) { - this.setState({ - reactivating: response.status === HttpStatusCode.Conflict, - submitting: false, - }); - } - }, - ); - }; - - handleNameChange = (event: React.SyntheticEvent) => { - const { value: name } = event.currentTarget; - this.setState((state) => ({ - name, - key: state.keyModifiedByUser ? state.key : latinize(name).replace(/[^A-Za-z0-9]/g, '_'), - })); - }; - - handleKeyChange = (event: React.SyntheticEvent) => - this.setState({ key: event.currentTarget.value, keyModifiedByUser: true }); - - handleDescriptionChange = (event: React.SyntheticEvent) => - this.setState({ description: event.currentTarget.value }); - - handleTypeChange = ({ value }: LabelValueSelectOption) => - this.setState({ type: value }); - - handleSeverityChange = ({ value }: { value: string }) => this.setState({ severity: value }); - - handleStatusChange = ({ value }: LabelValueSelectOption) => - this.setState({ status: value }); - - handleParameterChange = (event: React.SyntheticEvent) => { - const { name, value } = event.currentTarget; - this.setState((state: State) => ({ params: { ...state.params, [name]: value } })); - }; - - renderNameField = () => ( - - ( + - - ); - - renderKeyField = () => ( - - {this.props.customRule ? ( - {this.props.customRule.key} - ) : ( + > ) => { + setName(name); + setKey(keyModifiedByUser ? key : latinize(name).replace(/[^A-Za-z0-9]/g, '_')); + }} required size="full" type="text" - value={this.state.key} + value={name} /> - )} - + + ), + [key, keyModifiedByUser, name, submitting], ); - renderDescriptionField = () => ( - - ( + - - + > + {customRule ? ( + {customRule.key} + ) : ( + ) => { + setKey(event.currentTarget.value); + setKeyModifiedByUser(true); + }} + required + size="full" + type="text" + value={key} + /> + )} + + ), + [customRule, key, submitting], ); - renderTypeOption = (props: OptionProps, false>) => { - return ( - - - - ); - }; - - renderTypeSingleValue = (props: SingleValueProps, false>) => { - return ( - - - - ); - }; + const DescriptionField = React.useMemo( + () => ( + + ) => + setDescription(event.currentTarget.value) + } + required + rows={5} + size="full" + value={description} + /> + + + ), + [description, submitting], + ); - renderTypeField = () => { + const TypeField = React.useMemo(() => { const ruleTypeOption: LabelValueSelectOption[] = RULE_TYPES.map((type) => ({ label: translate('issue.type', type), value: type, @@ -262,39 +194,43 @@ export default class CustomRuleFormModal extends React.PureComponent) => setType(value)} components={{ - Option: this.renderTypeOption, - SingleValue: this.renderTypeSingleValue, + Option: TypeSelectOption, + SingleValue: TypeSelectValue, }} options={ruleTypeOption} - value={ruleTypeOption.find((t) => t.value === this.state.type)} + value={ruleTypeOption.find((t) => t.value === type)} /> ); - }; + }, [type, submitting]); - renderSeverityField = () => ( - - - + const SeverityField = React.useMemo( + () => ( + + setSeverity(value)} + severity={severity} + /> + + ), + [severity, submitting], ); - renderStatusField = () => { + const StatusField = React.useMemo(() => { const statusesOptions = RULE_STATUSES.map((status) => ({ label: translate('rules.status', status), value: status, })); + return ( ) => setStatus(value)} options={statusesOptions} isSearchable={false} - value={statusesOptions.find((s) => s.value === this.state.status)} + value={statusesOptions.find((s) => s.value === status)} /> ); - }; + }, [status, submitting]); - renderParameterField = (param: RuleParameter) => { - // Gets the actual value from params from the state. - // Without it, we have a issue with string 'constructor' as key - const actualValue = new Map(Object.entries(this.state.params)).get(param.key) ?? ''; + const handleParameterChange = React.useCallback( + (event: React.SyntheticEvent) => { + const { name, value } = event.currentTarget; + setParams({ ...params, [name]: value }); + }, + [params], + ); - return ( - - {param.type === 'TEXT' ? ( - - ) : ( - - )} - {param.htmlDesc !== undefined && ( - - )} - - ); - }; + const renderParameterField = React.useCallback( + (param: RuleParameter) => { + // Gets the actual value from params from the state. + // Without it, we have a issue with string 'constructor' as key + const actualValue = new Map(Object.entries(params)).get(param.key) ?? ''; - render() { - const { customRule, templateRule } = this.props; - const { reactivating, submitting } = this.state; - const { params = [] } = templateRule; - const header = customRule - ? translate('coding_rules.update_custom_rule') - : translate('coding_rules.create_custom_rule'); - let submit = translate(customRule ? 'save' : 'create'); - if (this.state.reactivating) { - submit = translate('coding_rules.reactivate'); - } - return ( - - {reactivating && ( - - {translate('coding_rules.reactivate.help')} - - )} + return ( + + {param.type === 'TEXT' ? ( + + ) : ( + + )} + {param.htmlDesc !== undefined && ( + + )} + + ); + }, + [params, submitting, handleParameterChange], + ); - + const { params: templateParams = [] } = templateRule; + const header = customRule + ? translate('coding_rules.update_custom_rule') + : translate('coding_rules.create_custom_rule'); + let buttonText = translate(customRule ? 'save' : 'create'); + if (reactivating) { + buttonText = translate('coding_rules.reactivate'); + } + return ( + ) => { + event.preventDefault(); + submit(); + }} + > + {reactivating && ( + + {translate('coding_rules.reactivate.help')} + + )} - {this.renderNameField()} - {this.renderKeyField()} - {/* do not allow to change the type of existing rule */} - {!customRule && this.renderTypeField()} - {this.renderSeverityField()} - {this.renderStatusField()} - {this.renderDescriptionField()} - {params.map(this.renderParameterField)} - - } - primaryButton={ - - {submit} - - } - loading={submitting} - secondaryButtonLabel={translate('cancel')} - /> - ); + + + {NameField} + {KeyField} + {/* do not allow to change the type of existing rule */} + {!customRule && TypeField} + {SeverityField} + {StatusField} + {DescriptionField} + {templateParams.map(renderParameterField)} + + } + primaryButton={ + + {buttonText} + + } + loading={submitting} + secondaryButtonLabel={translate('cancel')} + /> + ); +} + +function TypeSelectOption( + optionProps: Readonly, false>>, +) { + return ( + + + + ); +} + +function TypeSelectValue( + valueProps: Readonly, false>>, +) { + return ( + + + + ); +} + +function getParams(customRule?: RuleDetails) { + const params: Dict = {}; + + if (customRule?.params) { + for (const param of customRule.params) { + params[param.key] = param.defaultValue ?? ''; + } } + + return params; } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetails.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetails.tsx index ab5b9630624..0dc13d6d8f8 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetails.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetails.tsx @@ -29,12 +29,16 @@ import { } from 'design-system'; import * as React from 'react'; import { Profile } from '../../../api/quality-profiles'; -import { deleteRule, getRuleDetails, updateRule } from '../../../api/rules'; import ConfirmButton from '../../../components/controls/ConfirmButton'; import HelpTooltip from '../../../components/controls/HelpTooltip'; import DateFormatter from '../../../components/intl/DateFormatter'; import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { Dict, RuleActivation, RuleDetails as TypeRuleDetails } from '../../../types/types'; +import { + useDeleteRuleMutation, + useRuleDetailsQuery, + useUpdateRuleMutation, +} from '../../../queries/rules'; +import { Dict } from '../../../types/types'; import { Activation } from '../query'; import CustomRuleButton from './CustomRuleButton'; import RuleDetailsCustomRules from './RuleDetailsCustomRules'; @@ -57,223 +61,147 @@ interface Props { selectedProfile?: Profile; } -interface State { - actives?: RuleActivation[]; - loading: boolean; - ruleDetails?: TypeRuleDetails; -} - -export default class RuleDetails extends React.PureComponent { - mounted = false; - state: State = { loading: true }; - - componentDidMount() { - this.mounted = true; - this.setState({ loading: true }); - this.fetchRuleDetails(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.ruleKey !== this.props.ruleKey) { - this.setState({ loading: true }); - this.fetchRuleDetails(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - fetchRuleDetails = () => { - return getRuleDetails({ - actives: true, - key: this.props.ruleKey, - }).then( - ({ actives, rule }) => { - if (this.mounted) { - this.setState({ actives, loading: false, ruleDetails: rule }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }, - ); - }; - - handleRuleChange = (ruleDetails: TypeRuleDetails) => { - if (this.mounted) { - this.setState({ ruleDetails }); - } - }; - - handleTagsChange = (tags: string[]) => { - // optimistic update - const oldTags = this.state.ruleDetails && this.state.ruleDetails.tags; - this.setState((state) => - state.ruleDetails ? { ruleDetails: { ...state.ruleDetails, tags } } : null, - ); - updateRule({ - key: this.props.ruleKey, - tags: tags.join(), - }).catch(() => { - if (this.mounted) { - this.setState((state) => - state.ruleDetails ? { ruleDetails: { ...state.ruleDetails, tags: oldTags } } : null, - ); - } - }); +export default function RuleDetails(props: Readonly) { + const { + ruleKey, + allowCustomRules, + canWrite, + referencedProfiles, + canDeactivateInherited, + selectedProfile, + referencedRepositories, + } = props; + const { isLoading: loadingRule, data } = useRuleDetailsQuery({ + actives: true, + key: ruleKey, + }); + const { mutate: updateRule } = useUpdateRuleMutation(); + const { mutate: deleteRule } = useDeleteRuleMutation({}, props.onDelete); + + const { rule: ruleDetails, actives = [] } = data ?? {}; + + const params = ruleDetails?.params ?? []; + const isCustom = !!ruleDetails?.templateKey; + const isEditable = canWrite && !!allowCustomRules && isCustom; + + const handleTagsChange = (tags: string[]) => { + updateRule({ key: ruleKey, tags: tags.join() }); }; - handleActivate = () => { - return this.fetchRuleDetails().then(() => { - const { ruleKey, selectedProfile } = this.props; - if (selectedProfile && this.state.actives) { - const active = this.state.actives.find((active) => active.qProfile === selectedProfile.key); - if (active) { - this.props.onActivate(selectedProfile.key, ruleKey, active); - } + const handleActivate = () => { + if (selectedProfile) { + const active = actives.find((active) => active.qProfile === selectedProfile.key); + if (active) { + props.onActivate(selectedProfile.key, ruleKey, active); } - }); - }; - - handleDeactivate = () => { - return this.fetchRuleDetails().then(() => { - const { ruleKey, selectedProfile } = this.props; - if ( - selectedProfile && - this.state.actives && - !this.state.actives.find((active) => active.qProfile === selectedProfile.key) - ) { - this.props.onDeactivate(selectedProfile.key, ruleKey); - } - }); - }; - - handleDelete = () => { - return deleteRule({ key: this.props.ruleKey }).then(() => - this.props.onDelete(this.props.ruleKey), - ); + } }; - render() { - const { ruleDetails } = this.state; - - if (!ruleDetails) { - return
; + const handleDeactivate = () => { + if (selectedProfile && actives.find((active) => active.qProfile === selectedProfile.key)) { + props.onDeactivate(selectedProfile.key, ruleKey); } + }; - const { allowCustomRules, canWrite, referencedProfiles, canDeactivateInherited } = this.props; - const { params = [] } = ruleDetails; - - const isCustom = !!ruleDetails.templateKey; - const isEditable = canWrite && !!this.props.allowCustomRules && isCustom; - - return ( - - - + return ( + + + {ruleDetails && ( + <> + - + - {params.length > 0 && } + {params.length > 0 && } - {isEditable && ( -
- {/* `templateRule` is used to get rule meta data, `customRule` is used to get parameter values */} - {/* it's expected to pass the same rule to both parameters */} - - {({ onClick }) => ( - - {translate('edit')} - - )} - - - {({ onClick }) => ( - <> - + {/* `templateRule` is used to get rule meta data, `customRule` is used to get parameter values */} + {/* it's expected to pass the same rule to both parameters */} + + {({ onClick }) => ( + - {translate('delete')} - - - {translate('coding_rules.custom_rule.removal')} -
- } - > - - - - )} - + {translate('edit')} + + )} + + deleteRule({ key: ruleKey })} + > + {({ onClick }) => ( + <> + + {translate('delete')} + + + {translate('coding_rules.custom_rule.removal')} +
+ } + > + + + + )} + + + )} + + {ruleDetails.isTemplate && ( + + )} + + {!ruleDetails.isTemplate && ( + + )} + + {!ruleDetails.isTemplate && ruleDetails.type !== 'SECURITY_HOTSPOT' && ( + + )} + +
+ + {translate('coding_rules.available_since')} + +
- )} - - {ruleDetails.isTemplate && ( - - )} - - {!ruleDetails.isTemplate && ( - - )} - - {!ruleDetails.isTemplate && ruleDetails.type !== 'SECURITY_HOTSPOT' && ( - - )} - -
- - {translate('coding_rules.available_since')} - - -
- - - ); - } + + )} + + + ); } const StyledRuleDetails = styled.div` diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsCustomRules.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsCustomRules.tsx index 5a87734539c..074adafaf0f 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsCustomRules.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsCustomRules.tsx @@ -30,11 +30,11 @@ import { } from 'design-system'; import { sortBy } from 'lodash'; import * as React from 'react'; -import { deleteRule, searchRules } from '../../../api/rules'; import ConfirmButton from '../../../components/controls/ConfirmButton'; import IssueSeverityIcon from '../../../components/icon-mappers/IssueSeverityIcon'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getRuleUrl } from '../../../helpers/urls'; +import { useDeleteRuleMutation, useSearchRulesQuery } from '../../../queries/rules'; import { IssueSeverity } from '../../../types/issues'; import { Rule, RuleDetails } from '../../../types/types'; import CustomRuleButton from './CustomRuleButton'; @@ -44,72 +44,74 @@ interface Props { ruleDetails: RuleDetails; } -interface State { - loading: boolean; - rules?: Rule[]; -} - const COLUMN_COUNT = 3; const COLUMN_COUNT_WITH_EDIT_PERMISSIONS = 4; -export default class RuleDetailsCustomRules extends React.PureComponent { - mounted = false; - state: State = { loading: false }; - - componentDidMount() { - this.mounted = true; - this.fetchRules(); - } +export default function RuleDetailsCustomRules(props: Readonly) { + const { ruleDetails } = props; + const rulesSearchParams = { + f: 'name,severity,params', + template_key: ruleDetails.key, + }; + const { isLoading: loadingRules, data } = useSearchRulesQuery(rulesSearchParams); + const { mutate: deleteRules, isLoading: deletingRule } = useDeleteRuleMutation(rulesSearchParams); - componentDidUpdate(prevProps: Props) { - if (prevProps.ruleDetails.key !== this.props.ruleDetails.key) { - this.fetchRules(); - } - } + const loading = loadingRules || deletingRule; + const rules = data?.rules ?? []; - componentWillUnmount() { - this.mounted = false; - } + const handleRuleDelete = React.useCallback( + (ruleKey: string) => { + deleteRules({ key: ruleKey }); + }, + [deleteRules], + ); - fetchRules = () => { - this.setState({ loading: true }); - searchRules({ - f: 'name,severity,params', - template_key: this.props.ruleDetails.key, - }).then( - ({ rules }) => { - if (this.mounted) { - this.setState({ rules, loading: false }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }, - ); - }; + return ( +
+
+ {translate('coding_rules.custom_rules')} - handleRuleCreate = (newRuleDetails: RuleDetails) => { - if (this.mounted) { - this.setState(({ rules = [] }: State) => ({ - rules: [...rules, newRuleDetails], - })); - } - }; - - handleRuleDelete = (ruleKey: string) => { - return deleteRule({ key: ruleKey }).then(() => { - if (this.mounted) { - this.setState(({ rules = [] }) => ({ - rules: rules.filter((rule) => rule.key !== ruleKey), - })); - } - }); - }; + {props.canChange && ( + + {({ onClick }) => ( + + {translate('coding_rules.create')} + + )} + + )} + {rules.length > 0 && ( + + {sortBy(rules, (rule) => rule.name).map((rule) => ( + + ))} +
+ )} + +
+
+ ); +} - renderRule = (rule: Rule) => ( - +function RuleListItem( + props: Readonly<{ + rule: Rule; + editable?: boolean; + onDelete: (ruleKey: string) => void; + }>, +) { + const { rule, editable } = props; + return ( +
{rule.name} @@ -139,7 +141,7 @@ export default class RuleDetailsCustomRules extends React.PureComponent - {this.props.canChange && ( + {editable && ( {({ onClick }) => ( ); - - render() { - const { loading, rules = [] } = this.state; - - return ( -
-
- {translate('coding_rules.custom_rules')} - - {this.props.canChange && ( - - {({ onClick }) => ( - - {translate('coding_rules.create')} - - )} - - )} - - - {rules.length > 0 && ( - - {sortBy(rules, (rule) => rule.name).map(this.renderRule)} -
- )} -
-
-
- ); - } } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx index 832d94bfbcc..f3357a6a793 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx @@ -27,111 +27,55 @@ import { Spinner, } from 'design-system'; import * as React from 'react'; -import { updateRule } from '../../../api/rules'; import FormattingTips from '../../../components/common/FormattingTips'; import RuleTabViewer from '../../../components/rules/RuleTabViewer'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { sanitizeString, sanitizeUserInput } from '../../../helpers/sanitize'; +import { useUpdateRuleMutation } from '../../../queries/rules'; import { RuleDetails } from '../../../types/types'; import { RuleDescriptionSections } from '../rule'; import RemoveExtendedDescriptionModal from './RemoveExtendedDescriptionModal'; interface Props { canWrite: boolean | undefined; - onChange: (newRuleDetails: RuleDetails) => void; ruleDetails: RuleDetails; } -interface State { - description: string; - descriptionForm: boolean; - removeDescriptionModal: boolean; - submitting: boolean; -} - -export default class RuleDetailsDescription extends React.PureComponent { - mounted = false; - state: State = { - description: '', - descriptionForm: false, - removeDescriptionModal: false, - submitting: false, - }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - handleDescriptionChange = (event: React.SyntheticEvent) => - this.setState({ description: event.currentTarget.value }); - - handleCancelClick = () => { - this.setState({ descriptionForm: false }); - }; - - handleSaveClick = (event: React.SyntheticEvent) => { - event.preventDefault(); - this.updateDescription(this.state.description); - }; - - handleRemoveDescriptionClick = () => { - this.setState({ removeDescriptionModal: true }); - }; +export default function RuleDetailsDescription(props: Readonly) { + const { ruleDetails, canWrite } = props; + const [description, setDescription] = React.useState(''); + const [descriptionForm, setDescriptionForm] = React.useState(false); + const [removeDescriptionModal, setDescriptionModal] = React.useState(false); - handleCancelRemoving = () => this.setState({ removeDescriptionModal: false }); - - handleConfirmRemoving = () => { - this.setState({ removeDescriptionModal: false }); - this.updateDescription(''); - }; - - updateDescription = (text: string) => { - this.setState({ submitting: true }); + const { mutate: updateRule, isLoading: updatingRule } = useUpdateRuleMutation(() => + setDescriptionForm(false), + ); + const updateDescription = (text = '') => { updateRule({ - key: this.props.ruleDetails.key, + key: ruleDetails.key, markdown_note: text, - }).then( - (ruleDetails) => { - this.props.onChange(ruleDetails); - - if (this.mounted) { - this.setState({ submitting: false, descriptionForm: false }); - } - }, - () => { - if (this.mounted) { - this.setState({ submitting: false }); - } - }, - ); - }; - - handleExtendDescriptionClick = () => { - this.setState({ - // set description` to the current `mdNote` each time the form is open - description: this.props.ruleDetails.mdNote ?? '', - descriptionForm: true, }); }; - renderExtendedDescription = () => ( + const renderExtendedDescription = () => (
- {this.props.ruleDetails.htmlNote !== undefined && ( + {ruleDetails.htmlNote !== undefined && ( )}
- {this.props.canWrite && ( - + {canWrite && ( + { + setDescription(ruleDetails.mdNote ?? ''); + setDescriptionForm(true); + }} + > {translate('coding_rules.extend_description')} )} @@ -139,46 +83,54 @@ export default class RuleDetailsDescription extends React.PureComponent ); - renderForm = () => ( + const renderForm = () => (
) => { + event.preventDefault(); + updateDescription(description); + }} > ) => + setDescription(value) + } rows={4} - value={this.state.description} + value={description} />
{translate('save')} - {this.props.ruleDetails.mdNote !== undefined && ( + {ruleDetails.mdNote !== undefined && ( <> setDescriptionModal(true)} > {translate('remove')} - {this.state.removeDescriptionModal && ( + {removeDescriptionModal && ( setDescriptionModal(false)} + onSubmit={() => { + setDescriptionModal(false); + updateDescription(); + }} /> )} @@ -186,14 +138,14 @@ export default class RuleDetailsDescription extends React.PureComponent setDescriptionForm(false)} > {translate('cancel')} - +
@@ -201,55 +153,50 @@ export default class RuleDetailsDescription extends React.PureComponent ); - render() { - const { ruleDetails } = this.props; - const hasDescription = !ruleDetails.isExternal || ruleDetails.type !== 'UNKNOWN'; - - const hasDescriptionSection = - hasDescription && - ruleDetails.descriptionSections && - ruleDetails.descriptionSections.length > 0; - - const defaultSection = - hasDescriptionSection && - ruleDetails.descriptionSections?.length === 1 && - ruleDetails.descriptionSections[0].key === RuleDescriptionSections.DEFAULT - ? ruleDetails.descriptionSections[0] - : undefined; - - const introductionSection = ruleDetails.descriptionSections?.find( - (section) => section.key === RuleDescriptionSections.INTRODUCTION, - )?.content; - - return ( -
- {hasDescriptionSection && !defaultSection && ( - <> - {introductionSection && ( - - )} - - )} + const hasDescription = !ruleDetails.isExternal || ruleDetails.type !== 'UNKNOWN'; + + const hasDescriptionSection = + hasDescription && ruleDetails.descriptionSections && ruleDetails.descriptionSections.length > 0; + + const defaultSection = + hasDescriptionSection && + ruleDetails.descriptionSections?.length === 1 && + ruleDetails.descriptionSections[0].key === RuleDescriptionSections.DEFAULT + ? ruleDetails.descriptionSections[0] + : undefined; + + const introductionSection = ruleDetails.descriptionSections?.find( + (section) => section.key === RuleDescriptionSections.INTRODUCTION, + )?.content; + + return ( +
+ {hasDescriptionSection && !defaultSection && ( + <> + {introductionSection && ( + + )} + + )} - + - {ruleDetails.isExternal && ( -
- {translateWithParameters('issue.external_issue_description', ruleDetails.name)} -
- )} + {ruleDetails.isExternal && ( +
+ {translateWithParameters('issue.external_issue_description', ruleDetails.name)} +
+ )} - {!ruleDetails.templateKey && ( -
- {!this.state.descriptionForm && this.renderExtendedDescription()} - {this.state.descriptionForm && this.props.canWrite && this.renderForm()} -
- )} -
- ); - } + {!ruleDetails.templateKey && ( +
+ {!descriptionForm && renderExtendedDescription()} + {descriptionForm && canWrite && renderForm()} +
+ )} +
+ ); } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsProfiles.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsProfiles.tsx index f1776c6052f..8bb943bd736 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsProfiles.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsProfiles.tsx @@ -34,10 +34,14 @@ import { import { filter } from 'lodash'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Profile, activateRule, deactivateRule } from '../../../api/quality-profiles'; +import { Profile } from '../../../api/quality-profiles'; import ConfirmButton from '../../../components/controls/ConfirmButton'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getQualityProfileUrl } from '../../../helpers/urls'; +import { + useActivateRuleMutation, + useDeactivateRuleMutation, +} from '../../../queries/quality-profiles'; import { Dict, RuleActivation, RuleDetails } from '../../../types/types'; import BuiltInQualityProfileBadge from '../../quality-profiles/components/BuiltInQualityProfileBadge'; import ActivationButton from './ActivationButton'; @@ -45,8 +49,8 @@ import ActivationButton from './ActivationButton'; interface Props { activations: RuleActivation[] | undefined; canDeactivateInherited?: boolean; - onActivate: () => Promise; - onDeactivate: () => Promise; + onActivate: () => void; + onDeactivate: () => void; referencedProfiles: Dict; ruleDetails: RuleDetails; } @@ -56,84 +60,38 @@ const COLUMN_COUNT_WITHOUT_PARAMS = 2; const PROFILES_HEADING_ID = 'rule-details-profiles-heading'; -export default class RuleDetailsProfiles extends React.PureComponent { - handleActivate = () => this.props.onActivate(); +export default function RuleDetailsProfiles(props: Readonly) { + const { activations = [], referencedProfiles, ruleDetails, canDeactivateInherited } = props; + const { mutate: activateRule } = useActivateRuleMutation(props.onActivate); + const { mutate: deactivateRule } = useDeactivateRuleMutation(props.onDeactivate); + + const canActivate = Object.values(referencedProfiles).some((profile) => + Boolean(profile.actions?.edit && profile.language === ruleDetails.lang), + ); - handleDeactivate = (key?: string) => { + const handleDeactivate = (key?: string) => { if (key !== undefined) { deactivateRule({ key, - rule: this.props.ruleDetails.key, - }).then(this.props.onDeactivate, () => {}); + rule: ruleDetails.key, + }); } }; - handleRevert = (key?: string) => { + const handleRevert = (key?: string) => { if (key !== undefined) { activateRule({ key, - rule: this.props.ruleDetails.key, + rule: ruleDetails.key, reset: true, - }).then(this.props.onActivate, () => {}); + }); } }; - renderInheritedProfile = (activation: RuleActivation, profile: Profile) => { - if (!profile.parentName) { - return null; - } - const profilePath = getQualityProfileUrl(profile.parentName, profile.language); - return ( - (activation.inherit === 'OVERRIDES' || activation.inherit === 'INHERITED') && ( - - - - {profile.parentName} - - - ) - ); - }; - - renderParameter = (param: { key: string; value: string }, parentActivation?: RuleActivation) => { - const originalParam = parentActivation?.params.find((p) => p.key === param.key); - const originalValue = originalParam?.value; - - return ( - - {param.key} - : - - {param.value} - - {parentActivation && param.value !== originalValue && ( -
- {translate('coding_rules.original')} - - {originalValue} - -
- )} -
- ); - }; - - renderParameters = (activation: RuleActivation, parentActivation?: RuleActivation) => ( - - {activation.params.map((param) => this.renderParameter(param, parentActivation))} - - ); - - renderActions = (activation: RuleActivation, profile: Profile) => { + const renderRowActions = (activation: RuleActivation, profile: Profile) => { const canEdit = profile.actions?.edit && !profile.isBuiltIn; - const { ruleDetails } = this.props; const hasParent = activation.inherit !== 'NONE' && profile.parentKey; + return ( {canEdit && ( @@ -144,7 +102,7 @@ export default class RuleDetailsProfiles extends React.PureComponent { ariaLabel={translateWithParameters('coding_rules.change_details_x', profile.name)} buttonText={translate('change_verb')} modalHeader={translate('coding_rules.change_details')} - onDone={this.handleActivate} + onDone={props.onActivate} profiles={[profile]} rule={ruleDetails} /> @@ -160,7 +118,7 @@ export default class RuleDetailsProfiles extends React.PureComponent { )} isDestructive modalHeader={translate('coding_rules.revert_to_parent_definition')} - onConfirm={this.handleRevert} + onConfirm={handleRevert} > {({ onClick }) => ( @@ -170,13 +128,13 @@ export default class RuleDetailsProfiles extends React.PureComponent { )} - {(!hasParent || this.props.canDeactivateInherited) && ( + {(!hasParent || canDeactivateInherited) && ( {({ onClick }) => ( { ); }; - renderActivation = (activation: RuleActivation) => { - const { activations = [], ruleDetails } = this.props; - const profile = this.props.referencedProfiles[activation.qProfile]; + const renderActivationRow = (activation: RuleActivation) => { + const profile = referencedProfiles[activation.qProfile]; + if (!profile) { return null; } const parentActivation = activations.find((x) => x.qProfile === profile.parentKey); + const inheritedProfileSection = profile.parentName + ? (activation.inherit === 'OVERRIDES' || activation.inherit === 'INHERITED') && ( + + + + {profile.parentName} + + + ) + : null; + return ( @@ -220,57 +195,74 @@ export default class RuleDetailsProfiles extends React.PureComponent { {profile.isBuiltIn && }
- {this.renderInheritedProfile(activation, profile)} + {inheritedProfileSection}
- {!ruleDetails.templateKey && this.renderParameters(activation, parentActivation)} - {this.renderActions(activation, profile)} + {!ruleDetails.templateKey && ( + + {activation.params.map((param: { key: string; value: string }) => { + const originalParam = parentActivation?.params.find((p) => p.key === param.key); + const originalValue = originalParam?.value; + + return ( + + {param.key} + : + + {param.value} + + {parentActivation && param.value !== originalValue && ( +
+ {translate('coding_rules.original')} + + {originalValue} + +
+ )} +
+ ); + })} +
+ )} + {renderRowActions(activation, profile)} ); }; + return ( +
+ + + - render() { - const { activations = [], referencedProfiles, ruleDetails } = this.props; - const canActivate = Object.values(referencedProfiles).some((profile) => - Boolean(profile.actions?.edit && profile.language === ruleDetails.lang), - ); - - return ( -
- - - - - {canActivate && ( - !activations.find((activation) => activation.qProfile === profile.key), - )} - rule={ruleDetails} - /> - )} + {canActivate && ( + !activations.find((activation) => activation.qProfile === profile.key), + )} + rule={ruleDetails} + /> + )} - {activations.length > 0 && ( - - {activations.map(this.renderActivation)} -
- )} -
- ); - } + {activations.length > 0 && ( + + {activations.map(renderActivationRow)} +
+ )} +
+ ); } const StyledParameter = styled.div` 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 b668e0fab23..734c2155efc 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 @@ -192,6 +192,11 @@ export function getPageObjects() { }); }, + async detailsloaded() { + expect(await byRole('heading', { level: 1 }).find()).toBeInTheDocument(); + await ui.appLoaded(); + }, + async bulkActivate(rulesCount: number, profile: Profile) { await user.click(ui.bulkChangeButton.get()); await user.click(ui.activateIn.get()); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx index ed8fb4e07a3..969fa123cba 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx @@ -31,7 +31,7 @@ export function FiltersHeader({ displayReset, onReset }: Props) { return (
- + {displayReset && ( diff --git a/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx index abf0cfba149..57ac78f9c18 100644 --- a/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx @@ -19,7 +19,8 @@ */ import classNames from 'classnames'; -import { cloneDeep, debounce, groupBy } from 'lodash'; +import { ToggleButton } from 'design-system'; +import { cloneDeep, debounce, groupBy, isEqual } from 'lodash'; import * as React from 'react'; import { Location } from 'react-router-dom'; import { dismissNotice } from '../../api/users'; @@ -27,25 +28,17 @@ import { CurrentUserContextInterface } from '../../app/components/current-user/C import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; import { RuleDescriptionSections } from '../../apps/coding-rules/rule'; import { translate } from '../../helpers/l10n'; -import { Issue, RuleDetails } from '../../types/types'; +import { RuleDetails } from '../../types/types'; import { NoticeType } from '../../types/users'; import { getTabId, getTabPanelId } from '../controls/BoxedTabs'; import withLocation from '../hoc/withLocation'; import MoreInfoRuleDescription from './MoreInfoRuleDescription'; import RuleDescription from './RuleDescription'; - -import { ToggleButton } from 'design-system/lib'; import './style.css'; interface RuleTabViewerProps extends CurrentUserContextInterface { ruleDetails: RuleDetails; - extendedDescription?: string; - ruleDescriptionContextKey?: string; - activityTabContent?: React.ReactNode; location: Location; - selectedFlowIndex?: number; - selectedLocationIndex?: number; - issue?: Issue; } interface State { @@ -107,34 +100,15 @@ export class RuleTabViewer extends React.PureComponent - this.computeState( - pState, - prevProps.ruleDetails !== ruleDetails || - (prevProps.issue && issue && prevProps.issue.key !== issue.key) || - prevProps.selectedFlowIndex !== selectedFlowIndex || - prevProps.selectedLocationIndex !== selectedLocationIndex, - ), - ); + this.setState((pState) => this.computeState(pState, prevProps.ruleDetails !== ruleDetails)); } if (selectedTab?.value === TabKeys.MoreInfo) { @@ -178,8 +152,6 @@ export class RuleTabViewer extends React.PureComponent { const { ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType }, - extendedDescription, - activityTabContent, } = this.props; // As we might tamper with the description later on, we clone to avoid any side effect @@ -187,22 +159,6 @@ export class RuleTabViewer extends React.PureComponent section.key), ); - if (extendedDescription) { - if (descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]?.length > 0) { - // We add the extended description (htmlNote) in the first context, in case there are contexts - // Extended description will get reworked in future - descriptionSectionsByKey[RuleDescriptionSections.RESOURCES][0].content += - '
' + extendedDescription; - } else { - descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] = [ - { - content: extendedDescription, - key: RuleDescriptionSections.RESOURCES, - }, - ]; - } - } - const tabs: Tab[] = [ { content: (descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || @@ -241,11 +197,6 @@ export class RuleTabViewer extends React.PureComponent 0) || descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( diff --git a/server/sonar-web/src/main/js/queries/quality-profiles.ts b/server/sonar-web/src/main/js/queries/quality-profiles.ts index cc5367e3838..716ac775cbb 100644 --- a/server/sonar-web/src/main/js/queries/quality-profiles.ts +++ b/server/sonar-web/src/main/js/queries/quality-profiles.ts @@ -17,14 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { UseQueryResult, useMutation, useQuery } from '@tanstack/react-query'; +import { UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + ActivateRuleParameters, AddRemoveGroupParameters, AddRemoveUserParameters, + DeactivateRuleParameters, Profile, + activateRule, addGroup, addUser, compareProfiles, + deactivateRule, getProfileInheritance, } from '../api/quality-profiles'; import { ProfileInheritanceDetails } from '../types/types'; @@ -63,6 +67,30 @@ export function useProfilesCompareQuery(leftKey: string, rightKey: string) { }); } +export function useActivateRuleMutation(onSuccess: (data: ActivateRuleParameters) => unknown) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: activateRule, + onSuccess: (_, data) => { + queryClient.invalidateQueries({ queryKey: ['rules', 'details'] }); + onSuccess(data); + }, + }); +} + +export function useDeactivateRuleMutation(onSuccess: (data: DeactivateRuleParameters) => unknown) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deactivateRule, + onSuccess: (_, data) => { + queryClient.invalidateQueries({ queryKey: ['rules', 'details'] }); + onSuccess(data); + }, + }); +} + export function useAddUserMutation(onSuccess: () => unknown) { return useMutation({ mutationFn: (data: AddRemoveUserParameters) => addUser(data), diff --git a/server/sonar-web/src/main/js/queries/rules.ts b/server/sonar-web/src/main/js/queries/rules.ts new file mode 100644 index 00000000000..b7c303b2e67 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/rules.ts @@ -0,0 +1,117 @@ +/* + * 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createRule, deleteRule, getRuleDetails, searchRules, updateRule } from '../api/rules'; +import { SearchRulesResponse } from '../types/coding-rules'; +import { SearchRulesQuery } from '../types/rules'; +import { RuleActivation, RuleDetails } from '../types/types'; + +function getRulesQueryKey(type: 'search' | 'details', data?: SearchRulesQuery | string) { + const key = ['rules', type] as (string | SearchRulesQuery)[]; + if (data) { + key.push(data); + } + return key; +} + +export function useSearchRulesQuery(data: SearchRulesQuery) { + return useQuery({ + queryKey: getRulesQueryKey('search', data), + queryFn: ({ queryKey: [, , query] }) => { + if (!query) { + return null; + } + + return searchRules(data); + }, + }); +} + +export function useRuleDetailsQuery(data: { key: string; actives?: boolean }) { + return useQuery({ + queryKey: getRulesQueryKey('details', data.key), + queryFn: () => getRuleDetails(data), + }); +} + +export function useCreateRuleMutation( + searchQuery?: SearchRulesQuery, + onSuccess?: (rule: RuleDetails) => unknown, + onError?: (error: Response) => unknown, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createRule, + onError, + onSuccess: (rule) => { + onSuccess?.(rule); + queryClient.setQueryData( + getRulesQueryKey('search', searchQuery), + (oldData) => { + return oldData ? { ...oldData, rules: [rule, ...oldData.rules] } : undefined; + }, + ); + }, + }); +} + +export function useUpdateRuleMutation(onSuccess?: (rule: RuleDetails) => unknown) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateRule, + onSuccess: (rule) => { + onSuccess?.(rule); + queryClient.setQueryData<{ actives?: RuleActivation[]; rule: RuleDetails }>( + getRulesQueryKey('details', rule.key), + (oldData) => { + return { + ...oldData, + rule, + }; + }, + ); + }, + }); +} + +export function useDeleteRuleMutation( + searchQuery?: SearchRulesQuery, + onSuccess?: (key: string) => unknown, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { key: string }) => deleteRule(params), + onSuccess: (_, data) => { + onSuccess?.(data.key); + queryClient.setQueryData( + getRulesQueryKey('search', searchQuery), + (oldData) => { + return oldData + ? { ...oldData, rules: oldData.rules.filter((rule) => rule.key !== data.key) } + : undefined; + }, + ); + }, + }); +}