@@ -20,7 +20,7 @@ | |||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import CodingRulesMock from '../../../api/mocks/CodingRulesMock'; | |||
import { mockLoggedInUser } from '../../../helpers/testMocks'; | |||
import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks'; | |||
import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; | |||
import { CurrentUser } from '../../../types/users'; | |||
import routes from '../routes'; | |||
@@ -378,7 +378,7 @@ it('should handle hash parameters', async () => { | |||
it('should show notification for rule advanced section and remove it after user visits', async () => { | |||
const user = userEvent.setup(); | |||
renderCodingRulesApp(undefined, 'coding_rules?open=rule8'); | |||
renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8'); | |||
await screen.findByRole('heading', { | |||
level: 3, | |||
name: 'Awesome Python rule with education principles' | |||
@@ -422,7 +422,8 @@ it('should show notification for rule advanced section and remove it after user | |||
it('should show notification for rule advanced section and removes it when user scroll to the principles', async () => { | |||
const user = userEvent.setup(); | |||
renderCodingRulesApp(undefined, 'coding_rules?open=rule8'); | |||
renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8'); | |||
await screen.findByRole('heading', { | |||
level: 3, | |||
name: 'Awesome Python rule with education principles' | |||
@@ -463,7 +464,9 @@ it('should show notification for rule advanced section and removes it when user | |||
name: 'coding_rules.more_info.scroll_message' | |||
}) | |||
).toBeInTheDocument(); | |||
fireEvent.scroll(screen.getByText('coding_rules.more_info.education_principles.title')); | |||
// navigate away and come back | |||
await user.click( | |||
screen.getByRole('button', { | |||
@@ -478,6 +481,24 @@ it('should show notification for rule advanced section and removes it when user | |||
expect(screen.queryByText('coding_rules.more_info.notification_message')).not.toBeInTheDocument(); | |||
}); | |||
it('should not show notification for anonymous users', async () => { | |||
const user = userEvent.setup(); | |||
renderCodingRulesApp(mockCurrentUser(), 'coding_rules?open=rule8'); | |||
await user.click( | |||
await screen.findByRole('button', { | |||
name: 'coding_rules.description_section.title.more_info' | |||
}) | |||
); | |||
expect(screen.queryByText('coding_rules.more_info.notification_message')).not.toBeInTheDocument(); | |||
expect( | |||
screen.queryByRole('button', { | |||
name: 'coding_rules.more_info.scroll_message' | |||
}) | |||
).not.toBeInTheDocument(); | |||
}); | |||
function renderCodingRulesApp(currentUser?: CurrentUser, navigateTo?: string) { | |||
renderAppRoutes('coding_rules', routes, { | |||
navigateTo, |
@@ -17,87 +17,32 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { groupBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import RuleDescription from '../../../components/rules/RuleDescription'; | |||
import TabViewer, { | |||
getHowToFixTab, | |||
getMoreInfoTab, | |||
getWhyIsThisAnIssueTab, | |||
Tab, | |||
TabKeys | |||
} from '../../../components/rules/TabViewer'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import TabViewer from '../../../components/rules/TabViewer'; | |||
import { sanitizeString } from '../../../helpers/sanitize'; | |||
import { RuleDetails } from '../../../types/types'; | |||
import { RuleDescriptionSections } from '../rule'; | |||
interface Props { | |||
export interface RuleTabViewerProps { | |||
ruleDetails: RuleDetails; | |||
} | |||
export default class RuleTabViewer extends React.PureComponent<Props> { | |||
computeTabs = (showNotice: boolean, educationPrinciplesRef: React.RefObject<HTMLDivElement>) => { | |||
const { ruleDetails } = this.props; | |||
const descriptionSectionsByKey = groupBy( | |||
ruleDetails.descriptionSections, | |||
section => section.key | |||
); | |||
const hasEducationPrinciples = | |||
!!ruleDetails.educationPrinciples && ruleDetails.educationPrinciples.length > 0; | |||
const showNotification = showNotice && hasEducationPrinciples; | |||
export default function RuleTabViewer(props: RuleTabViewerProps) { | |||
const { ruleDetails } = props; | |||
const introduction = ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.INTRODUCTION | |||
)?.content; | |||
const rootCauseTitle = | |||
ruleDetails.type === 'SECURITY_HOTSPOT' | |||
? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT') | |||
: translate('coding_rules.description_section.title.root_cause'); | |||
return [ | |||
getWhyIsThisAnIssueTab( | |||
descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE], | |||
descriptionSectionsByKey, | |||
rootCauseTitle | |||
), | |||
{ | |||
key: TabKeys.AssessTheIssue, | |||
label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), | |||
content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( | |||
<RuleDescription | |||
className="big-padded" | |||
sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]} | |||
/> | |||
) | |||
}, | |||
getHowToFixTab( | |||
descriptionSectionsByKey, | |||
translate('coding_rules.description_section.title', TabKeys.HowToFixIt) | |||
), | |||
getMoreInfoTab( | |||
showNotification, | |||
descriptionSectionsByKey, | |||
educationPrinciplesRef, | |||
translate('coding_rules.description_section.title', TabKeys.MoreInfo), | |||
ruleDetails.educationPrinciples | |||
) | |||
].filter(tab => tab.content) as Array<Tab>; | |||
}; | |||
render() { | |||
const { ruleDetails } = this.props; | |||
const intro = ruleDetails.descriptionSections?.find( | |||
section => section.key === RuleDescriptionSections.INTRODUCTION | |||
)?.content; | |||
return ( | |||
<> | |||
{intro && ( | |||
<div | |||
className="rule-desc" | |||
// eslint-disable-next-line react/no-danger | |||
dangerouslySetInnerHTML={{ __html: sanitizeString(intro) }} | |||
/> | |||
)} | |||
<TabViewer ruleDetails={this.props.ruleDetails} computeTabs={this.computeTabs} /> | |||
</> | |||
); | |||
} | |||
return ( | |||
<> | |||
{introduction && ( | |||
<div | |||
className="rule-desc" | |||
// eslint-disable-next-line react/no-danger | |||
dangerouslySetInnerHTML={{ __html: sanitizeString(introduction) }} | |||
/> | |||
)} | |||
<TabViewer ruleDetails={ruleDetails} /> | |||
</> | |||
); | |||
} |
@@ -22,8 +22,10 @@ import userEvent from '@testing-library/user-event'; | |||
import React from 'react'; | |||
import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; | |||
import { renderOwaspTop102021Category } from '../../../helpers/security-standard'; | |||
import { mockCurrentUser } from '../../../helpers/testMocks'; | |||
import { renderApp, renderAppRoutes } from '../../../helpers/testReactTestingUtils'; | |||
import { IssueType } from '../../../types/issues'; | |||
import { CurrentUser } from '../../../types/users'; | |||
import IssuesApp from '../components/IssuesApp'; | |||
import { projectIssuesRoutes } from '../routes'; | |||
@@ -42,14 +44,16 @@ beforeEach(() => { | |||
it('should show education principles', async () => { | |||
const user = userEvent.setup(); | |||
renderProjectIssuesApp('project/issues?issues=issue2&open=issue2&id=myproject'); | |||
await user.click(await screen.findByRole('button', { name: `issue.tabs.more_info` })); | |||
await user.click( | |||
await screen.findByRole('button', { name: `coding_rules.description_section.title.more_info` }) | |||
); | |||
expect(screen.getByRole('heading', { name: 'Defense-In-Depth', level: 3 })).toBeInTheDocument(); | |||
}); | |||
it('should open issue and navigate', async () => { | |||
const user = userEvent.setup(); | |||
renderIssueApp(); | |||
renderIssueApp(mockCurrentUser()); | |||
// Select an issue with an advanced rule | |||
expect(await screen.findByRole('region', { name: 'Fix that' })).toBeInTheDocument(); | |||
@@ -60,13 +64,21 @@ it('should open issue and navigate', async () => { | |||
expect(screen.getByRole('link', { name: 'advancedRuleId' })).toBeInTheDocument(); | |||
// Select the "why is this an issue" tab and check its content | |||
expect(screen.getByRole('button', { name: `issue.tabs.why` })).toBeInTheDocument(); | |||
await user.click(screen.getByRole('button', { name: `issue.tabs.why` })); | |||
expect( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.root_cause` }) | |||
).toBeInTheDocument(); | |||
await user.click( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.root_cause` }) | |||
); | |||
expect(screen.getByRole('heading', { name: 'Because' })).toBeInTheDocument(); | |||
// Select the "how to fix it" tab | |||
expect(screen.getByRole('button', { name: `issue.tabs.how_to_fix` })).toBeInTheDocument(); | |||
await user.click(screen.getByRole('button', { name: `issue.tabs.how_to_fix` })); | |||
expect( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.how_to_fix` }) | |||
).toBeInTheDocument(); | |||
await user.click( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.how_to_fix` }) | |||
); | |||
// Is the context selector present with the expected values and default selection? | |||
expect(screen.getByRole('button', { name: 'Context 2' })).toBeInTheDocument(); | |||
@@ -88,8 +100,12 @@ it('should open issue and navigate', async () => { | |||
expect(screen.getByText('coding_rules.context.others.description.second')).toBeInTheDocument(); | |||
// Select the main info tab and check its content | |||
expect(screen.getByRole('button', { name: `issue.tabs.more_info` })).toBeInTheDocument(); | |||
await user.click(screen.getByRole('button', { name: `issue.tabs.more_info` })); | |||
expect( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.more_info` }) | |||
).toBeInTheDocument(); | |||
await user.click( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.more_info` }) | |||
); | |||
expect(screen.getByRole('heading', { name: 'Link' })).toBeInTheDocument(); | |||
// check for extended description | |||
@@ -104,8 +120,12 @@ it('should open issue and navigate', async () => { | |||
expect(screen.getByRole('link', { name: 'simpleRuleId' })).toBeInTheDocument(); | |||
// Select the "why is this an issue tab" and check its content | |||
expect(screen.getByRole('button', { name: `issue.tabs.why` })).toBeInTheDocument(); | |||
await user.click(screen.getByRole('button', { name: `issue.tabs.why` })); | |||
expect( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.root_cause` }) | |||
).toBeInTheDocument(); | |||
await user.click( | |||
screen.getByRole('button', { name: `coding_rules.description_section.title.root_cause` }) | |||
); | |||
expect(screen.getByRole('heading', { name: 'Default' })).toBeInTheDocument(); | |||
// Select the previous issue (with a simple rule) through keyboard shortcut | |||
@@ -115,9 +135,7 @@ it('should open issue and navigate', async () => { | |||
expect(screen.getByRole('heading', { level: 1, name: 'Issue on file' })).toBeInTheDocument(); | |||
expect(screen.getByRole('link', { name: 'simpleRuleId' })).toBeInTheDocument(); | |||
// Select the "Where is the issue" tab and check its content | |||
expect(screen.getByRole('button', { name: `issue.tabs.code` })).toBeInTheDocument(); | |||
await user.click(screen.getByRole('button', { name: `issue.tabs.code` })); | |||
// The "Where is the issue" tab should be selected by default. Check its content | |||
expect(screen.getByRole('region', { name: 'Issue on file' })).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('row', { | |||
@@ -171,8 +189,8 @@ describe('redirects', () => { | |||
}); | |||
}); | |||
function renderIssueApp() { | |||
renderApp('project/issues', <IssuesApp />); | |||
function renderIssueApp(currentUser?: CurrentUser) { | |||
renderApp('project/issues', <IssuesApp />, { currentUser: mockCurrentUser(), ...currentUser }); | |||
} | |||
function renderProjectIssuesApp(navigateTo?: string) { |
@@ -18,102 +18,51 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import classNames from 'classnames'; | |||
import { groupBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { Link } from 'react-router-dom'; | |||
import TabViewer, { | |||
getHowToFixTab, | |||
getMoreInfoTab, | |||
getWhyIsThisAnIssueTab, | |||
Tab, | |||
TabKeys | |||
} from '../../../components/rules/TabViewer'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import TabViewer from '../../../components/rules/TabViewer'; | |||
import { getRuleUrl } from '../../../helpers/urls'; | |||
import { Component, Issue, RuleDetails } from '../../../types/types'; | |||
import { RuleDescriptionSections } from '../../coding-rules/rule'; | |||
interface Props { | |||
interface IssueViewerTabsProps { | |||
component?: Component; | |||
issue: Issue; | |||
codeTabContent: React.ReactNode; | |||
ruleDetails: RuleDetails; | |||
} | |||
export default class IssueViewerTabs extends React.PureComponent<Props> { | |||
computeTabs = (showNotice: boolean, educationPrinciplesRef: React.RefObject<HTMLDivElement>) => { | |||
const { | |||
ruleDetails, | |||
codeTabContent, | |||
issue: { ruleDescriptionContextKey } | |||
} = this.props; | |||
const descriptionSectionsByKey = groupBy( | |||
ruleDetails.descriptionSections, | |||
section => section.key | |||
); | |||
const hasEducationPrinciples = | |||
!!ruleDetails.educationPrinciples && ruleDetails.educationPrinciples.length > 0; | |||
const showNotification = showNotice && hasEducationPrinciples; | |||
const rootCauseDescriptionSections = | |||
descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || | |||
descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]; | |||
return [ | |||
{ | |||
key: TabKeys.Code, | |||
label: translate('issue.tabs', TabKeys.Code), | |||
content: <div className="padded">{codeTabContent}</div> | |||
}, | |||
getWhyIsThisAnIssueTab( | |||
rootCauseDescriptionSections, | |||
descriptionSectionsByKey, | |||
translate('issue.tabs', TabKeys.WhyIsThisAnIssue), | |||
ruleDescriptionContextKey | |||
), | |||
getHowToFixTab( | |||
descriptionSectionsByKey, | |||
translate('issue.tabs', TabKeys.HowToFixIt), | |||
ruleDescriptionContextKey | |||
), | |||
getMoreInfoTab( | |||
showNotification, | |||
descriptionSectionsByKey, | |||
educationPrinciplesRef, | |||
translate('issue.tabs', TabKeys.MoreInfo), | |||
ruleDetails.educationPrinciples | |||
) | |||
].filter(tab => tab.content) as Array<Tab>; | |||
}; | |||
render() { | |||
const { ruleDetails, codeTabContent } = this.props; | |||
const { | |||
component, | |||
ruleDetails: { name, key }, | |||
issue: { message } | |||
} = this.props; | |||
return ( | |||
<> | |||
<div | |||
className={classNames('issue-header', { | |||
'issue-project-level': component !== undefined | |||
})}> | |||
<h1 className="text-bold">{message}</h1> | |||
<div className="spacer-top big-spacer-bottom"> | |||
<span className="note padded-right">{name}</span> | |||
<Link className="small" to={getRuleUrl(key)} target="_blank"> | |||
{key} | |||
</Link> | |||
</div> | |||
export default function IssueViewerTabs(props: IssueViewerTabsProps) { | |||
const { | |||
ruleDetails, | |||
codeTabContent, | |||
issue: { ruleDescriptionContextKey } | |||
} = props; | |||
const { | |||
component, | |||
ruleDetails: { name, key }, | |||
issue: { message } | |||
} = props; | |||
return ( | |||
<> | |||
<div | |||
className={classNames('issue-header', { | |||
'issue-project-level': component !== undefined | |||
})}> | |||
<h1 className="text-bold">{message}</h1> | |||
<div className="spacer-top big-spacer-bottom"> | |||
<span className="note padded-right">{name}</span> | |||
<Link className="small" to={getRuleUrl(key)} target="_blank"> | |||
{key} | |||
</Link> | |||
</div> | |||
<TabViewer | |||
ruleDetails={ruleDetails} | |||
computeTabs={this.computeTabs} | |||
codeTabContent={codeTabContent} | |||
pageType="issues" | |||
/> | |||
</> | |||
); | |||
} | |||
</div> | |||
<TabViewer | |||
ruleDetails={ruleDetails} | |||
extendedDescription={ruleDetails.htmlNote} | |||
ruleDescriptionContextKey={ruleDescriptionContextKey} | |||
codeTabContent={codeTabContent} | |||
pageType="issues" | |||
/> | |||
</> | |||
); | |||
} |
@@ -19,7 +19,7 @@ | |||
*/ | |||
import styled from '@emotion/styled'; | |||
import classNames from 'classnames'; | |||
import { debounce, groupBy, keyBy, omit, without } from 'lodash'; | |||
import { debounce, keyBy, omit, without } from 'lodash'; | |||
import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
import { FormattedMessage } from 'react-intl'; | |||
@@ -74,7 +74,6 @@ import { | |||
import { SecurityStandard } from '../../../types/security'; | |||
import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../types/types'; | |||
import { CurrentUser, UserBase } from '../../../types/users'; | |||
import { RuleDescriptionSections } from '../../coding-rules/rule'; | |||
import * as actions from '../actions'; | |||
import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList'; | |||
import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader'; | |||
@@ -355,40 +354,13 @@ export class App extends React.PureComponent<Props, State> { | |||
} | |||
this.setState({ loadingRule: true }); | |||
const openRuleDetails = await getRuleDetails({ key: openIssue.rule }) | |||
.then(response => { | |||
const ruleDetails = response.rule; | |||
this.addExtendedDescription(ruleDetails); | |||
return ruleDetails; | |||
}) | |||
.then(response => response.rule) | |||
.catch(() => undefined); | |||
if (this.mounted) { | |||
this.setState({ loadingRule: false, openRuleDetails }); | |||
} | |||
} | |||
addExtendedDescription = (ruleDetails: RuleDetails) => { | |||
const descriptionSectionsByKey = groupBy( | |||
ruleDetails.descriptionSections, | |||
section => section.key | |||
); | |||
if (ruleDetails.htmlNote) { | |||
if (descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] !== undefined) { | |||
// 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 += | |||
'<br/>' + ruleDetails.htmlNote; | |||
} else { | |||
descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] = [ | |||
{ | |||
key: RuleDescriptionSections.RESOURCES, | |||
content: ruleDetails.htmlNote | |||
} | |||
]; | |||
} | |||
} | |||
}; | |||
selectPreviousIssue = () => { | |||
const { issues } = this.state; | |||
const selectedIndex = this.getSelectedIndex(); |
@@ -21,7 +21,6 @@ import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { searchIssues } from '../../../../api/issues'; | |||
import { getRuleDetails } from '../../../../api/rules'; | |||
import TabViewer from '../../../../components/rules/TabViewer'; | |||
import handleRequiredAuthentication from '../../../../helpers/handleRequiredAuthentication'; | |||
import { KeyboardKeys } from '../../../../helpers/keycodes'; | |||
import { mockPullRequest } from '../../../../helpers/mocks/branch-like'; | |||
@@ -54,8 +53,6 @@ import { | |||
} from '../../actions'; | |||
import BulkChangeModal from '../BulkChangeModal'; | |||
import { App } from '../IssuesApp'; | |||
import IssuesSourceViewer from '../IssuesSourceViewer'; | |||
import IssueViewerTabs from '../IssueTabViewer'; | |||
jest.mock('../../../../helpers/pages', () => ({ | |||
addSideBarClass: jest.fn(), | |||
@@ -209,24 +206,6 @@ it('should open standard facets for vulnerabilities and hotspots', () => { | |||
expect(fetchFacet).lastCalledWith('owaspTop10'); | |||
}); | |||
it('should switch to source view if an issue is selected', async () => { | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.find(IssueViewerTabs).exists()).toBe(false); | |||
wrapper.setProps({ location: mockLocation({ query: { open: 'third' } }) }); | |||
await waitAndUpdate(wrapper); | |||
expect( | |||
wrapper | |||
.find(IssueViewerTabs) | |||
.dive() | |||
.find(TabViewer) | |||
.dive() | |||
.find(IssuesSourceViewer) | |||
.exists() | |||
).toBe(true); | |||
}); | |||
it('should correctly bind key events for issue navigation', async () => { | |||
const push = jest.fn(); | |||
const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); |
@@ -31,7 +31,7 @@ import './style.css'; | |||
interface Props { | |||
sections?: RuleDescriptionSection[]; | |||
educationPrinciples?: string[]; | |||
showNotification?: boolean; | |||
displayEducationalPrinciplesNotification?: boolean; | |||
educationPrinciplesRef?: React.RefObject<HTMLDivElement>; | |||
} | |||
@@ -48,10 +48,15 @@ export default class MoreInfoRuleDescription extends React.PureComponent<Props, | |||
}; | |||
render() { | |||
const { showNotification, sections = [], educationPrinciples = [] } = this.props; | |||
const { | |||
displayEducationalPrinciplesNotification, | |||
sections = [], | |||
educationPrinciples = [], | |||
educationPrinciplesRef | |||
} = this.props; | |||
return ( | |||
<div className="big-padded rule-desc"> | |||
{showNotification && ( | |||
{displayEducationalPrinciplesNotification && ( | |||
<Alert variant="info"> | |||
<p className="little-spacer-bottom little-spacer-top"> | |||
{translate('coding_rules.more_info.notification_message')} | |||
@@ -73,7 +78,7 @@ export default class MoreInfoRuleDescription extends React.PureComponent<Props, | |||
{educationPrinciples.length > 0 && ( | |||
<> | |||
<h2 ref={this.props.educationPrinciplesRef}> | |||
<h2 ref={educationPrinciplesRef}> | |||
{translate('coding_rules.more_info.education_principles.title')} | |||
</h2> | |||
{educationPrinciples.map(key => { |
@@ -18,30 +18,33 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import classNames from 'classnames'; | |||
import { debounce, Dictionary } from 'lodash'; | |||
import { cloneDeep, debounce, groupBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { dismissNotice, getCurrentUser } from '../../api/users'; | |||
import { RuleDescriptionSection, RuleDescriptionSections } from '../../apps/coding-rules/rule'; | |||
import { dismissNotice } from '../../api/users'; | |||
import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext'; | |||
import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; | |||
import { RuleDescriptionSections } from '../../apps/coding-rules/rule'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { RuleDetails } from '../../types/types'; | |||
import { CurrentUser, NoticeType } from '../../types/users'; | |||
import { NoticeType } from '../../types/users'; | |||
import BoxedTabs from '../controls/BoxedTabs'; | |||
import MoreInfoRuleDescription from './MoreInfoRuleDescription'; | |||
import RuleDescription from './RuleDescription'; | |||
import './style.css'; | |||
interface Props { | |||
interface TabViewerProps extends CurrentUserContextInterface { | |||
ruleDetails: RuleDetails; | |||
extendedDescription?: string; | |||
ruleDescriptionContextKey?: string; | |||
codeTabContent?: React.ReactNode; | |||
computeTabs: ( | |||
showNotice: boolean, | |||
educationPrinciplesRef: React.RefObject<HTMLDivElement> | |||
) => Tab[]; | |||
pageType?: string; | |||
} | |||
interface State { | |||
currentTab: Tab; | |||
tabs: Tab[]; | |||
selectedTab?: Tab; | |||
displayEducationalPrinciplesNotification?: boolean; | |||
educationalPrinciplesNotificationHasBeenDismissed?: boolean; | |||
} | |||
export interface Tab { | |||
@@ -60,62 +63,209 @@ export enum TabKeys { | |||
const DEBOUNCE_FOR_SCROLL = 250; | |||
export default class TabViewer extends React.PureComponent<Props, State> { | |||
showNotification = false; | |||
export class TabViewer extends React.PureComponent<TabViewerProps, State> { | |||
state: State = { | |||
tabs: [] | |||
}; | |||
educationPrinciplesRef: React.RefObject<HTMLDivElement>; | |||
constructor(props: Props) { | |||
constructor(props: TabViewerProps) { | |||
super(props); | |||
const tabs = this.getUpdatedTabs(false); | |||
this.state = { | |||
tabs, | |||
currentTab: tabs[0] | |||
}; | |||
this.educationPrinciplesRef = React.createRef(); | |||
this.checkIfConceptIsVisible = debounce(this.checkIfConceptIsVisible, DEBOUNCE_FOR_SCROLL); | |||
document.addEventListener('scroll', this.checkIfConceptIsVisible, { capture: true }); | |||
this.checkIfEducationPrinciplesAreVisible = debounce( | |||
this.checkIfEducationPrinciplesAreVisible, | |||
DEBOUNCE_FOR_SCROLL | |||
); | |||
} | |||
componentDidMount() { | |||
this.getNotificationValue(); | |||
this.setState(prevState => this.computeState(prevState)); | |||
this.attachScrollEvent(); | |||
} | |||
componentDidUpdate(prevProps: Props, prevState: State) { | |||
const { currentTab } = this.state; | |||
componentDidUpdate(prevProps: TabViewerProps, prevState: State) { | |||
const { ruleDetails, codeTabContent, ruleDescriptionContextKey, currentUser } = this.props; | |||
const { selectedTab } = this.state; | |||
if ( | |||
prevProps.ruleDetails !== this.props.ruleDetails || | |||
prevProps.codeTabContent !== this.props.codeTabContent | |||
prevProps.ruleDetails.key !== ruleDetails.key || | |||
prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey || | |||
prevProps.codeTabContent !== codeTabContent || | |||
prevProps.currentUser !== currentUser | |||
) { | |||
const tabs = this.getUpdatedTabs(this.showNotification); | |||
this.getNotificationValue(); | |||
this.setState({ | |||
tabs, | |||
currentTab: tabs[0] | |||
}); | |||
this.setState(pState => this.computeState(pState, prevProps.ruleDetails !== ruleDetails)); | |||
} | |||
if (currentTab.key === TabKeys.MoreInfo) { | |||
this.checkIfConceptIsVisible(); | |||
if (selectedTab?.key === TabKeys.MoreInfo) { | |||
this.checkIfEducationPrinciplesAreVisible(); | |||
} | |||
if (prevState.currentTab.key === TabKeys.MoreInfo && !this.showNotification) { | |||
const tabs = this.getUpdatedTabs(this.showNotification); | |||
this.setState({ tabs }); | |||
if ( | |||
prevState.selectedTab?.key === TabKeys.MoreInfo && | |||
prevState.displayEducationalPrinciplesNotification && | |||
prevState.educationalPrinciplesNotificationHasBeenDismissed | |||
) { | |||
this.props.updateDismissedNotices(NoticeType.EDUCATION_PRINCIPLES, true); | |||
} | |||
} | |||
componentWillUnmount() { | |||
document.removeEventListener('scroll', this.checkIfConceptIsVisible, { capture: true }); | |||
this.detachScrollEvent(); | |||
} | |||
checkIfConceptIsVisible = () => { | |||
computeState = (prevState: State, resetSelectedTab: boolean = false) => { | |||
const { | |||
ruleDetails, | |||
currentUser: { isLoggedIn, dismissedNotices } | |||
} = this.props; | |||
const displayEducationalPrinciplesNotification = | |||
!!ruleDetails.educationPrinciples && | |||
ruleDetails.educationPrinciples.length > 0 && | |||
isLoggedIn && | |||
!dismissedNotices[NoticeType.EDUCATION_PRINCIPLES]; | |||
const tabs = this.computeTabs(displayEducationalPrinciplesNotification); | |||
return { | |||
tabs, | |||
selectedTab: resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab, | |||
displayEducationalPrinciplesNotification | |||
}; | |||
}; | |||
computeTabs = (displayEducationalPrinciplesNotification: boolean) => { | |||
const { | |||
codeTabContent, | |||
ruleDetails: { descriptionSections, educationPrinciples, type: ruleType }, | |||
ruleDescriptionContextKey, | |||
extendedDescription | |||
} = this.props; | |||
// As we might tamper with the description later on, we clone to avoid any side effect | |||
const descriptionSectionsByKey = cloneDeep( | |||
groupBy(descriptionSections, section => 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 += | |||
'<br/>' + extendedDescription; | |||
} else { | |||
descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] = [ | |||
{ | |||
key: RuleDescriptionSections.RESOURCES, | |||
content: extendedDescription | |||
} | |||
]; | |||
} | |||
} | |||
const tabs: Tab[] = [ | |||
{ | |||
key: TabKeys.WhyIsThisAnIssue, | |||
label: | |||
ruleType === 'SECURITY_HOTSPOT' | |||
? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT') | |||
: translate('coding_rules.description_section.title.root_cause'), | |||
content: (descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || | |||
descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]) && ( | |||
<RuleDescription | |||
className="big-padded" | |||
sections={ | |||
descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || | |||
descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE] | |||
} | |||
isDefault={descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] !== undefined} | |||
defaultContextKey={ruleDescriptionContextKey} | |||
/> | |||
) | |||
}, | |||
{ | |||
key: TabKeys.AssessTheIssue, | |||
label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), | |||
content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( | |||
<RuleDescription | |||
className="big-padded" | |||
sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]} | |||
/> | |||
) | |||
}, | |||
{ | |||
key: TabKeys.HowToFixIt, | |||
label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt), | |||
content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( | |||
<RuleDescription | |||
className="big-padded" | |||
sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} | |||
defaultContextKey={ruleDescriptionContextKey} | |||
/> | |||
) | |||
}, | |||
{ | |||
key: TabKeys.MoreInfo, | |||
label: ( | |||
<> | |||
{translate('coding_rules.description_section.title', TabKeys.MoreInfo)} | |||
{displayEducationalPrinciplesNotification && <div className="notice-dot" />} | |||
</> | |||
), | |||
content: ((educationPrinciples && educationPrinciples.length > 0) || | |||
descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( | |||
<MoreInfoRuleDescription | |||
educationPrinciples={educationPrinciples} | |||
sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} | |||
displayEducationalPrinciplesNotification={displayEducationalPrinciplesNotification} | |||
educationPrinciplesRef={this.educationPrinciplesRef} | |||
/> | |||
) | |||
} | |||
]; | |||
if (codeTabContent !== undefined) { | |||
tabs.unshift({ | |||
key: TabKeys.Code, | |||
label: translate('issue.tabs', TabKeys.Code), | |||
content: <div className="padded">{codeTabContent}</div> | |||
}); | |||
} | |||
return tabs.filter(tab => tab.content); | |||
}; | |||
attachScrollEvent = () => { | |||
document.addEventListener('scroll', this.checkIfEducationPrinciplesAreVisible, { | |||
capture: true | |||
}); | |||
}; | |||
detachScrollEvent = () => { | |||
document.removeEventListener('scroll', this.checkIfEducationPrinciplesAreVisible, { | |||
capture: true | |||
}); | |||
}; | |||
checkIfEducationPrinciplesAreVisible = () => { | |||
const { | |||
displayEducationalPrinciplesNotification, | |||
educationalPrinciplesNotificationHasBeenDismissed | |||
} = this.state; | |||
if (this.educationPrinciplesRef.current) { | |||
const rect = this.educationPrinciplesRef.current.getBoundingClientRect(); | |||
const isView = rect.top <= (window.innerHeight || document.documentElement.clientHeight); | |||
if (isView && this.showNotification) { | |||
const isVisible = rect.top <= (window.innerHeight || document.documentElement.clientHeight); | |||
if ( | |||
isVisible && | |||
displayEducationalPrinciplesNotification && | |||
!educationalPrinciplesNotificationHasBeenDismissed | |||
) { | |||
dismissNotice(NoticeType.EDUCATION_PRINCIPLES) | |||
.then(() => { | |||
document.removeEventListener('scroll', this.checkIfConceptIsVisible, { capture: true }); | |||
this.showNotification = false; | |||
this.detachScrollEvent(); | |||
this.setState({ educationalPrinciplesNotificationHasBeenDismissed: true }); | |||
}) | |||
.catch(() => { | |||
/* noop */ | |||
@@ -124,34 +274,22 @@ export default class TabViewer extends React.PureComponent<Props, State> { | |||
} | |||
}; | |||
getNotificationValue() { | |||
getCurrentUser() | |||
.then((data: CurrentUser) => { | |||
const educationPrinciplesDismissed = data.dismissedNotices[NoticeType.EDUCATION_PRINCIPLES]; | |||
if (educationPrinciplesDismissed !== undefined) { | |||
this.showNotification = !educationPrinciplesDismissed; | |||
const tabs = this.getUpdatedTabs(!educationPrinciplesDismissed); | |||
this.setState({ tabs }); | |||
} | |||
}) | |||
.catch(() => { | |||
/* noop */ | |||
}); | |||
} | |||
handleSelectTabs = (currentTabKey: TabKeys) => { | |||
this.setState(({ tabs }) => ({ | |||
currentTab: tabs.find(tab => tab.key === currentTabKey) || tabs[0] | |||
selectedTab: tabs.find(tab => tab.key === currentTabKey) || tabs[0] | |||
})); | |||
}; | |||
getUpdatedTabs = (showNotification: boolean) => { | |||
return this.props.computeTabs(showNotification, this.educationPrinciplesRef); | |||
}; | |||
render() { | |||
const { tabs, currentTab } = this.state; | |||
const { tabs, selectedTab } = this.state; | |||
const { pageType } = this.props; | |||
if (!tabs || tabs.length === 0 || !selectedTab) { | |||
return null; | |||
} | |||
const tabContent = tabs.find(t => t.key === selectedTab.key)?.content; | |||
return ( | |||
<> | |||
<div | |||
@@ -161,79 +299,14 @@ export default class TabViewer extends React.PureComponent<Props, State> { | |||
<BoxedTabs | |||
className="big-spacer-top" | |||
onSelect={this.handleSelectTabs} | |||
selected={currentTab.key} | |||
selected={selectedTab.key} | |||
tabs={tabs} | |||
/> | |||
</div> | |||
<div className="bordered">{currentTab.content}</div> | |||
<div className="bordered">{tabContent}</div> | |||
</> | |||
); | |||
} | |||
} | |||
export const getMoreInfoTab = ( | |||
showNotification: boolean, | |||
descriptionSectionsByKey: Dictionary<RuleDescriptionSection[]>, | |||
educationPrinciplesRef: React.RefObject<HTMLDivElement>, | |||
title: string, | |||
educationPrinciples?: string[] | |||
) => { | |||
return { | |||
key: TabKeys.MoreInfo, | |||
label: showNotification ? ( | |||
<div> | |||
{title} | |||
<div className="notice-dot" /> | |||
</div> | |||
) : ( | |||
title | |||
), | |||
content: ((educationPrinciples && educationPrinciples.length > 0) || | |||
descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( | |||
<MoreInfoRuleDescription | |||
educationPrinciples={educationPrinciples} | |||
sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} | |||
showNotification={showNotification} | |||
educationPrinciplesRef={educationPrinciplesRef} | |||
/> | |||
) | |||
}; | |||
}; | |||
export const getHowToFixTab = ( | |||
descriptionSectionsByKey: Dictionary<RuleDescriptionSection[]>, | |||
title: string, | |||
ruleDescriptionContextKey?: string | |||
) => { | |||
return { | |||
key: TabKeys.HowToFixIt, | |||
label: title, | |||
content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( | |||
<RuleDescription | |||
className="big-padded" | |||
sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} | |||
defaultContextKey={ruleDescriptionContextKey} | |||
/> | |||
) | |||
}; | |||
}; | |||
export const getWhyIsThisAnIssueTab = ( | |||
rootCauseDescriptionSections: RuleDescriptionSection[], | |||
descriptionSectionsByKey: Dictionary<RuleDescriptionSection[]>, | |||
title: string, | |||
ruleDescriptionContextKey?: string | |||
) => { | |||
return { | |||
key: TabKeys.WhyIsThisAnIssue, | |||
label: title, | |||
content: rootCauseDescriptionSections && ( | |||
<RuleDescription | |||
className="big-padded" | |||
sections={rootCauseDescriptionSections} | |||
isDefault={descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] !== undefined} | |||
defaultContextKey={ruleDescriptionContextKey} | |||
/> | |||
) | |||
}; | |||
}; | |||
export default withCurrentUserContext(TabViewer); |
@@ -860,9 +860,6 @@ issue.transition.resolveasreviewed.description=There is no Vulnerability in the | |||
issue.transition.resetastoreview=Reset as To Review | |||
issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again | |||
issue.tabs.code=Where is the issue? | |||
issue.tabs.why=Why is this an issue? | |||
issue.tabs.how_to_fix=How can I fix it? | |||
issue.tabs.more_info=More Info | |||
vulnerability.transition.resetastoreview=Reset as To Review | |||
vulnerability.transition.resetastoreview.description=The vulnerability can't be fixed as is and needs more details. The security hotspot needs to be reviewed again | |||
@@ -1912,7 +1909,7 @@ coding_rules.external_rule.engine=Rule provided by an external rule engine: {0} | |||
coding_rules.description_section.title.introduction=Introduction | |||
coding_rules.description_section.title.root_cause=Why is this an issue? | |||
coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT=What is the risk? | |||
coding_rules.description_section.title.assess_the_problem=Assess the risk? | |||
coding_rules.description_section.title.assess_the_problem=Assess the risk | |||
coding_rules.description_section.title.how_to_fix=How can I fix it? | |||
coding_rules.description_section.title.more_info=More Info | |||