@@ -81,6 +81,7 @@ it('should show open rule with no description', async () => { | |||
}); | |||
it('should show hotspot rule section', async () => { | |||
const user = userEvent.setup(); | |||
renderCodingRulesApp(undefined, 'coding_rules?open=rule2'); | |||
expect(await screen.findByRole('heading', { level: 3, name: 'Hot hotspot' })).toBeInTheDocument(); | |||
expect( | |||
@@ -89,25 +90,31 @@ it('should show hotspot rule section', async () => { | |||
}) | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('heading', { | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT' | |||
}) | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('heading', { | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.assess_the_problem' | |||
}) | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('heading', { | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.resources' | |||
}) | |||
).toBeInTheDocument(); | |||
// Check that we render plain html | |||
await user.click( | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.resources' | |||
}) | |||
); | |||
expect(screen.getByRole('link', { name: 'Awsome Reading' })).toBeInTheDocument(); | |||
}); | |||
it('should show rule advanced section', async () => { | |||
const user = userEvent.setup(); | |||
renderCodingRulesApp(undefined, 'coding_rules?open=rule5'); | |||
expect( | |||
await screen.findByRole('heading', { level: 3, name: 'Awsome Python rule' }) | |||
@@ -118,16 +125,21 @@ it('should show rule advanced section', async () => { | |||
}) | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('heading', { | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.how_to_fix' | |||
}) | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('heading', { | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.resources' | |||
}) | |||
).toBeInTheDocument(); | |||
// Check that we render plain html | |||
await user.click( | |||
screen.getByRole('button', { | |||
name: 'coding_rules.description_section.title.resources' | |||
}) | |||
); | |||
expect(screen.getByRole('link', { name: 'Awsome Reading' })).toBeInTheDocument(); | |||
}); | |||
@@ -17,29 +17,15 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { sortBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { updateRule } from '../../../api/rules'; | |||
import FormattingTips from '../../../components/common/FormattingTips'; | |||
import { Button, ResetButtonLink } from '../../../components/controls/buttons'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { sanitizeString } from '../../../helpers/sanitize'; | |||
import { | |||
Dict, | |||
RuleDescriptionSection, | |||
RuleDescriptionSections, | |||
RuleDetails | |||
} from '../../../types/types'; | |||
import { RuleDescriptionSection, RuleDescriptionSections, RuleDetails } from '../../../types/types'; | |||
import RemoveExtendedDescriptionModal from './RemoveExtendedDescriptionModal'; | |||
const SECTION_ORDER: Dict<number> = { | |||
[RuleDescriptionSections.DEFAULT]: 0, | |||
[RuleDescriptionSections.INTRODUCTION]: 1, | |||
[RuleDescriptionSections.ROOT_CAUSE]: 2, | |||
[RuleDescriptionSections.ASSESS_THE_PROBLEM]: 3, | |||
[RuleDescriptionSections.HOW_TO_FIX]: 4, | |||
[RuleDescriptionSections.RESOURCES]: 5 | |||
}; | |||
import RuleTabViewer from './RuleTabViewer'; | |||
interface Props { | |||
canWrite: boolean | undefined; | |||
@@ -122,13 +108,6 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S | |||
}); | |||
}; | |||
sortedDescriptionSections(ruleDetails: RuleDetails) { | |||
return sortBy( | |||
ruleDetails.descriptionSections?.filter(section => SECTION_ORDER[section.key] !== undefined), | |||
s => SECTION_ORDER[s.key] | |||
); | |||
} | |||
renderExtendedDescription = () => ( | |||
<div id="coding-rules-detail-description-extra"> | |||
{this.props.ruleDetails.htmlNote !== undefined && ( | |||
@@ -240,12 +219,34 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S | |||
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; | |||
return ( | |||
<div className="js-rule-description"> | |||
{hasDescription && | |||
ruleDetails.descriptionSections && | |||
ruleDetails.descriptionSections.length > 0 ? ( | |||
this.sortedDescriptionSections(ruleDetails).map(this.renderDescription) | |||
{defaultSection && ( | |||
<> | |||
<h2>{translate('coding_rules.description_section.title.root_cause')}</h2> | |||
<section | |||
className="coding-rules-detail-description markdown" | |||
key={defaultSection.key} | |||
/* eslint-disable-next-line react/no-danger */ | |||
dangerouslySetInnerHTML={{ __html: sanitizeString(defaultSection.content) }} | |||
/> | |||
</> | |||
)} | |||
{hasDescriptionSection && !defaultSection ? ( | |||
<RuleTabViewer ruleDetails={ruleDetails} /> | |||
) : ( | |||
<div className="coding-rules-detail-description rule-desc markdown"> | |||
{translateWithParameters('issue.external_issue_description', ruleDetails.name)} |
@@ -0,0 +1,145 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 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 * as React from 'react'; | |||
import BoxedTabs from '../../../components/controls/BoxedTabs'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { sanitizeString } from '../../../helpers/sanitize'; | |||
import { RuleDescriptionSections, RuleDetails } from '../../../types/types'; | |||
interface Props { | |||
ruleDetails: RuleDetails; | |||
} | |||
interface State { | |||
currentTab: Tab; | |||
tabs: Tab[]; | |||
} | |||
interface Tab { | |||
key: TabKeys; | |||
label: React.ReactNode; | |||
content: string; | |||
} | |||
enum TabKeys { | |||
WhyIsThisAnIssue = 'why', | |||
HowToFixIt = 'how_to_fix', | |||
AssessTheIssue = 'assess_the_problem', | |||
Resources = 'resources' | |||
} | |||
export default class RuleViewerTabs extends React.PureComponent<Props, State> { | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = this.computeState(); | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.ruleDetails !== this.props.ruleDetails) { | |||
this.setState(this.computeState()); | |||
} | |||
} | |||
handleSelectTabs = (currentTabKey: TabKeys) => { | |||
this.setState(({ tabs }) => ({ | |||
currentTab: tabs.find(tab => tab.key === currentTabKey) || tabs[0] | |||
})); | |||
}; | |||
computeState() { | |||
const { ruleDetails } = this.props; | |||
const tabs = [ | |||
{ | |||
key: TabKeys.WhyIsThisAnIssue, | |||
label: | |||
ruleDetails.type === 'SECURITY_HOTSPOT' | |||
? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT') | |||
: translate('coding_rules.description_section.title.root_cause'), | |||
content: ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.ROOT_CAUSE | |||
)?.content | |||
}, | |||
{ | |||
key: TabKeys.AssessTheIssue, | |||
label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), | |||
content: ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.ASSESS_THE_PROBLEM | |||
)?.content | |||
}, | |||
{ | |||
key: TabKeys.HowToFixIt, | |||
label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt), | |||
content: ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.HOW_TO_FIX | |||
)?.content | |||
}, | |||
{ | |||
key: TabKeys.Resources, | |||
label: translate('coding_rules.description_section.title', TabKeys.Resources), | |||
content: ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.RESOURCES | |||
)?.content | |||
} | |||
].filter(tab => tab.content !== undefined) as Array<Tab>; | |||
return { | |||
currentTab: tabs[0], | |||
tabs | |||
}; | |||
} | |||
render() { | |||
const { ruleDetails } = this.props; | |||
const { tabs, currentTab } = this.state; | |||
const intro = ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.INTRODUCTION | |||
)?.content; | |||
return ( | |||
<> | |||
{intro && ( | |||
<> | |||
<h2>{translate('coding_rules.description_section.title.introduction')}</h2> | |||
<div | |||
className="big-padded rule-desc" | |||
// eslint-disable-next-line react/no-danger | |||
dangerouslySetInnerHTML={{ __html: sanitizeString(intro) }} | |||
/> | |||
</> | |||
)} | |||
<BoxedTabs | |||
className="bordered-bottom" | |||
onSelect={this.handleSelectTabs} | |||
selected={currentTab.key} | |||
tabs={tabs} | |||
/> | |||
<div className="bordered-right bordered-left bordered-bottom huge-spacer-bottom"> | |||
<div | |||
className="big-padded rule-desc" | |||
// eslint-disable-next-line react/no-danger | |||
dangerouslySetInnerHTML={{ __html: sanitizeString(currentTab.content) }} | |||
/> | |||
</div> | |||
</> | |||
); | |||
} | |||
} |
@@ -32,7 +32,7 @@ exports[`should render correctly: loaded 1`] = ` | |||
"descriptionSections": Array [ | |||
Object { | |||
"content": "<b>Why</b> Because", | |||
"key": "root_cause", | |||
"key": "default", | |||
}, | |||
], | |||
"htmlDesc": "", | |||
@@ -74,7 +74,7 @@ exports[`should render correctly: loaded 1`] = ` | |||
"descriptionSections": Array [ | |||
Object { | |||
"content": "<b>Why</b> Because", | |||
"key": "root_cause", | |||
"key": "default", | |||
}, | |||
], | |||
"htmlDesc": "", | |||
@@ -147,7 +147,7 @@ exports[`should render correctly: loaded 1`] = ` | |||
"descriptionSections": Array [ | |||
Object { | |||
"content": "<b>Why</b> Because", | |||
"key": "root_cause", | |||
"key": "default", | |||
}, | |||
], | |||
"htmlDesc": "", | |||
@@ -188,7 +188,7 @@ exports[`should render correctly: loaded 1`] = ` | |||
"descriptionSections": Array [ | |||
Object { | |||
"content": "<b>Why</b> Because", | |||
"key": "root_cause", | |||
"key": "default", | |||
}, | |||
], | |||
"htmlDesc": "", |
@@ -45,7 +45,7 @@ interface Tab { | |||
isDefault: boolean; | |||
} | |||
export enum TabKeys { | |||
enum TabKeys { | |||
Code = 'code', | |||
WhyIsThisAnIssue = 'why', | |||
HowToFixIt = 'how', |
@@ -570,7 +570,7 @@ export function mockRuleDetails(overrides: Partial<RuleDetails> = {}): RuleDetai | |||
createdAt: '2014-12-16T17:26:54+0100', | |||
descriptionSections: [ | |||
{ | |||
key: RuleDescriptionSections.ROOT_CAUSE, | |||
key: RuleDescriptionSections.DEFAULT, | |||
content: '<b>Why</b> Because' | |||
} | |||
], |