Browse Source

SONAR-16599 Use withCurrentUserContext + refactoring

tags/9.6.0.59041
Philippe Perrin 1 year ago
parent
commit
015d68fb1c

+ 24
- 3
server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts View File

import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import CodingRulesMock from '../../../api/mocks/CodingRulesMock'; import CodingRulesMock from '../../../api/mocks/CodingRulesMock';
import { mockLoggedInUser } from '../../../helpers/testMocks';
import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; import { renderAppRoutes } from '../../../helpers/testReactTestingUtils';
import { CurrentUser } from '../../../types/users'; import { CurrentUser } from '../../../types/users';
import routes from '../routes'; import routes from '../routes';


it('should show notification for rule advanced section and remove it after user visits', async () => { it('should show notification for rule advanced section and remove it after user visits', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderCodingRulesApp(undefined, 'coding_rules?open=rule8');
renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8');
await screen.findByRole('heading', { await screen.findByRole('heading', {
level: 3, level: 3,
name: 'Awesome Python rule with education principles' name: 'Awesome Python rule with education principles'


it('should show notification for rule advanced section and removes it when user scroll to the principles', async () => { it('should show notification for rule advanced section and removes it when user scroll to the principles', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderCodingRulesApp(undefined, 'coding_rules?open=rule8');
renderCodingRulesApp(mockLoggedInUser(), 'coding_rules?open=rule8');

await screen.findByRole('heading', { await screen.findByRole('heading', {
level: 3, level: 3,
name: 'Awesome Python rule with education principles' name: 'Awesome Python rule with education principles'
name: 'coding_rules.more_info.scroll_message' name: 'coding_rules.more_info.scroll_message'
}) })
).toBeInTheDocument(); ).toBeInTheDocument();

fireEvent.scroll(screen.getByText('coding_rules.more_info.education_principles.title')); fireEvent.scroll(screen.getByText('coding_rules.more_info.education_principles.title'));

// navigate away and come back // navigate away and come back
await user.click( await user.click(
screen.getByRole('button', { screen.getByRole('button', {
expect(screen.queryByText('coding_rules.more_info.notification_message')).not.toBeInTheDocument(); 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) { function renderCodingRulesApp(currentUser?: CurrentUser, navigateTo?: string) {
renderAppRoutes('coding_rules', routes, { renderAppRoutes('coding_rules', routes, {
navigateTo, navigateTo,

+ 19
- 74
server/sonar-web/src/main/js/apps/coding-rules/components/RuleTabViewer.tsx View File

* along with this program; if not, write to the Free Software Foundation, * along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/ */
import { groupBy } from 'lodash';
import * as React from 'react'; 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 { sanitizeString } from '../../../helpers/sanitize';
import { RuleDetails } from '../../../types/types'; import { RuleDetails } from '../../../types/types';
import { RuleDescriptionSections } from '../rule'; import { RuleDescriptionSections } from '../rule';


interface Props {
export interface RuleTabViewerProps {
ruleDetails: RuleDetails; 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} />
</>
);
} }

+ 33
- 15
server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx View File

import React from 'react'; import React from 'react';
import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock';
import { renderOwaspTop102021Category } from '../../../helpers/security-standard'; import { renderOwaspTop102021Category } from '../../../helpers/security-standard';
import { mockCurrentUser } from '../../../helpers/testMocks';
import { renderApp, renderAppRoutes } from '../../../helpers/testReactTestingUtils'; import { renderApp, renderAppRoutes } from '../../../helpers/testReactTestingUtils';
import { IssueType } from '../../../types/issues'; import { IssueType } from '../../../types/issues';
import { CurrentUser } from '../../../types/users';
import IssuesApp from '../components/IssuesApp'; import IssuesApp from '../components/IssuesApp';
import { projectIssuesRoutes } from '../routes'; import { projectIssuesRoutes } from '../routes';


it('should show education principles', async () => { it('should show education principles', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderProjectIssuesApp('project/issues?issues=issue2&open=issue2&id=myproject'); 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(); expect(screen.getByRole('heading', { name: 'Defense-In-Depth', level: 3 })).toBeInTheDocument();
}); });


it('should open issue and navigate', async () => { it('should open issue and navigate', async () => {
const user = userEvent.setup(); const user = userEvent.setup();


renderIssueApp();
renderIssueApp(mockCurrentUser());


// Select an issue with an advanced rule // Select an issue with an advanced rule
expect(await screen.findByRole('region', { name: 'Fix that' })).toBeInTheDocument(); expect(await screen.findByRole('region', { name: 'Fix that' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'advancedRuleId' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'advancedRuleId' })).toBeInTheDocument();


// Select the "why is this an issue" tab and check its content // 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(); expect(screen.getByRole('heading', { name: 'Because' })).toBeInTheDocument();


// Select the "how to fix it" tab // 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? // Is the context selector present with the expected values and default selection?
expect(screen.getByRole('button', { name: 'Context 2' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Context 2' })).toBeInTheDocument();
expect(screen.getByText('coding_rules.context.others.description.second')).toBeInTheDocument(); expect(screen.getByText('coding_rules.context.others.description.second')).toBeInTheDocument();


// Select the main info tab and check its content // 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(); expect(screen.getByRole('heading', { name: 'Link' })).toBeInTheDocument();


// check for extended description // check for extended description
expect(screen.getByRole('link', { name: 'simpleRuleId' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'simpleRuleId' })).toBeInTheDocument();


// Select the "why is this an issue tab" and check its content // 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(); expect(screen.getByRole('heading', { name: 'Default' })).toBeInTheDocument();


// Select the previous issue (with a simple rule) through keyboard shortcut // Select the previous issue (with a simple rule) through keyboard shortcut
expect(screen.getByRole('heading', { level: 1, name: 'Issue on file' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1, name: 'Issue on file' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'simpleRuleId' })).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('region', { name: 'Issue on file' })).toBeInTheDocument();
expect( expect(
screen.getByRole('row', { screen.getByRole('row', {
}); });
}); });


function renderIssueApp() {
renderApp('project/issues', <IssuesApp />);
function renderIssueApp(currentUser?: CurrentUser) {
renderApp('project/issues', <IssuesApp />, { currentUser: mockCurrentUser(), ...currentUser });
} }


function renderProjectIssuesApp(navigateTo?: string) { function renderProjectIssuesApp(navigateTo?: string) {

+ 35
- 86
server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx View File

* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/ */
import classNames from 'classnames'; import classNames from 'classnames';
import { groupBy } from 'lodash';
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router-dom'; 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 { getRuleUrl } from '../../../helpers/urls';
import { Component, Issue, RuleDetails } from '../../../types/types'; import { Component, Issue, RuleDetails } from '../../../types/types';
import { RuleDescriptionSections } from '../../coding-rules/rule';


interface Props {
interface IssueViewerTabsProps {
component?: Component; component?: Component;
issue: Issue; issue: Issue;
codeTabContent: React.ReactNode; codeTabContent: React.ReactNode;
ruleDetails: RuleDetails; 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> </div>
<TabViewer
ruleDetails={ruleDetails}
computeTabs={this.computeTabs}
codeTabContent={codeTabContent}
pageType="issues"
/>
</>
);
}
</div>
<TabViewer
ruleDetails={ruleDetails}
extendedDescription={ruleDetails.htmlNote}
ruleDescriptionContextKey={ruleDescriptionContextKey}
codeTabContent={codeTabContent}
pageType="issues"
/>
</>
);
} }

+ 2
- 30
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx View File

*/ */
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import classNames from 'classnames'; 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 * as React from 'react';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { SecurityStandard } from '../../../types/security'; import { SecurityStandard } from '../../../types/security';
import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../types/types'; import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../types/types';
import { CurrentUser, UserBase } from '../../../types/users'; import { CurrentUser, UserBase } from '../../../types/users';
import { RuleDescriptionSections } from '../../coding-rules/rule';
import * as actions from '../actions'; import * as actions from '../actions';
import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList'; import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList';
import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader'; import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader';
} }
this.setState({ loadingRule: true }); this.setState({ loadingRule: true });
const openRuleDetails = await getRuleDetails({ key: openIssue.rule }) const openRuleDetails = await getRuleDetails({ key: openIssue.rule })
.then(response => {
const ruleDetails = response.rule;
this.addExtendedDescription(ruleDetails);
return ruleDetails;
})
.then(response => response.rule)
.catch(() => undefined); .catch(() => undefined);
if (this.mounted) { if (this.mounted) {
this.setState({ loadingRule: false, openRuleDetails }); 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 = () => { selectPreviousIssue = () => {
const { issues } = this.state; const { issues } = this.state;
const selectedIndex = this.getSelectedIndex(); const selectedIndex = this.getSelectedIndex();

+ 0
- 21
server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx View File

import * as React from 'react'; import * as React from 'react';
import { searchIssues } from '../../../../api/issues'; import { searchIssues } from '../../../../api/issues';
import { getRuleDetails } from '../../../../api/rules'; import { getRuleDetails } from '../../../../api/rules';
import TabViewer from '../../../../components/rules/TabViewer';
import handleRequiredAuthentication from '../../../../helpers/handleRequiredAuthentication'; import handleRequiredAuthentication from '../../../../helpers/handleRequiredAuthentication';
import { KeyboardKeys } from '../../../../helpers/keycodes'; import { KeyboardKeys } from '../../../../helpers/keycodes';
import { mockPullRequest } from '../../../../helpers/mocks/branch-like'; import { mockPullRequest } from '../../../../helpers/mocks/branch-like';
} from '../../actions'; } from '../../actions';
import BulkChangeModal from '../BulkChangeModal'; import BulkChangeModal from '../BulkChangeModal';
import { App } from '../IssuesApp'; import { App } from '../IssuesApp';
import IssuesSourceViewer from '../IssuesSourceViewer';
import IssueViewerTabs from '../IssueTabViewer';


jest.mock('../../../../helpers/pages', () => ({ jest.mock('../../../../helpers/pages', () => ({
addSideBarClass: jest.fn(), addSideBarClass: jest.fn(),
expect(fetchFacet).lastCalledWith('owaspTop10'); 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 () => { it('should correctly bind key events for issue navigation', async () => {
const push = jest.fn(); const push = jest.fn();
const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); const addEventListenerSpy = jest.spyOn(document, 'addEventListener');

+ 9
- 4
server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx View File

interface Props { interface Props {
sections?: RuleDescriptionSection[]; sections?: RuleDescriptionSection[];
educationPrinciples?: string[]; educationPrinciples?: string[];
showNotification?: boolean;
displayEducationalPrinciplesNotification?: boolean;
educationPrinciplesRef?: React.RefObject<HTMLDivElement>; educationPrinciplesRef?: React.RefObject<HTMLDivElement>;
} }


}; };


render() { render() {
const { showNotification, sections = [], educationPrinciples = [] } = this.props;
const {
displayEducationalPrinciplesNotification,
sections = [],
educationPrinciples = [],
educationPrinciplesRef
} = this.props;
return ( return (
<div className="big-padded rule-desc"> <div className="big-padded rule-desc">
{showNotification && (
{displayEducationalPrinciplesNotification && (
<Alert variant="info"> <Alert variant="info">
<p className="little-spacer-bottom little-spacer-top"> <p className="little-spacer-bottom little-spacer-top">
{translate('coding_rules.more_info.notification_message')} {translate('coding_rules.more_info.notification_message')}


{educationPrinciples.length > 0 && ( {educationPrinciples.length > 0 && (
<> <>
<h2 ref={this.props.educationPrinciplesRef}>
<h2 ref={educationPrinciplesRef}>
{translate('coding_rules.more_info.education_principles.title')} {translate('coding_rules.more_info.education_principles.title')}
</h2> </h2>
{educationPrinciples.map(key => { {educationPrinciples.map(key => {

+ 204
- 131
server/sonar-web/src/main/js/components/rules/TabViewer.tsx View File

* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/ */
import classNames from 'classnames'; import classNames from 'classnames';
import { debounce, Dictionary } from 'lodash';
import { cloneDeep, debounce, groupBy } from 'lodash';
import * as React from 'react'; 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 { RuleDetails } from '../../types/types';
import { CurrentUser, NoticeType } from '../../types/users';
import { NoticeType } from '../../types/users';
import BoxedTabs from '../controls/BoxedTabs'; import BoxedTabs from '../controls/BoxedTabs';
import MoreInfoRuleDescription from './MoreInfoRuleDescription'; import MoreInfoRuleDescription from './MoreInfoRuleDescription';
import RuleDescription from './RuleDescription'; import RuleDescription from './RuleDescription';
import './style.css'; import './style.css';


interface Props {
interface TabViewerProps extends CurrentUserContextInterface {
ruleDetails: RuleDetails; ruleDetails: RuleDetails;
extendedDescription?: string;
ruleDescriptionContextKey?: string;
codeTabContent?: React.ReactNode; codeTabContent?: React.ReactNode;
computeTabs: (
showNotice: boolean,
educationPrinciplesRef: React.RefObject<HTMLDivElement>
) => Tab[];
pageType?: string; pageType?: string;
} }


interface State { interface State {
currentTab: Tab;
tabs: Tab[]; tabs: Tab[];
selectedTab?: Tab;
displayEducationalPrinciplesNotification?: boolean;
educationalPrinciplesNotificationHasBeenDismissed?: boolean;
} }


export interface Tab { export interface Tab {


const DEBOUNCE_FOR_SCROLL = 250; 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>; educationPrinciplesRef: React.RefObject<HTMLDivElement>;


constructor(props: Props) {
constructor(props: TabViewerProps) {
super(props); super(props);
const tabs = this.getUpdatedTabs(false);
this.state = {
tabs,
currentTab: tabs[0]
};
this.educationPrinciplesRef = React.createRef(); 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() { 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 ( 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() { 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) { if (this.educationPrinciplesRef.current) {
const rect = this.educationPrinciplesRef.current.getBoundingClientRect(); 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) dismissNotice(NoticeType.EDUCATION_PRINCIPLES)
.then(() => { .then(() => {
document.removeEventListener('scroll', this.checkIfConceptIsVisible, { capture: true });
this.showNotification = false;
this.detachScrollEvent();
this.setState({ educationalPrinciplesNotificationHasBeenDismissed: true });
}) })
.catch(() => { .catch(() => {
/* noop */ /* noop */
} }
}; };


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) => { handleSelectTabs = (currentTabKey: TabKeys) => {
this.setState(({ tabs }) => ({ 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() { render() {
const { tabs, currentTab } = this.state;
const { tabs, selectedTab } = this.state;
const { pageType } = this.props; const { pageType } = this.props;

if (!tabs || tabs.length === 0 || !selectedTab) {
return null;
}

const tabContent = tabs.find(t => t.key === selectedTab.key)?.content;

return ( return (
<> <>
<div <div
<BoxedTabs <BoxedTabs
className="big-spacer-top" className="big-spacer-top"
onSelect={this.handleSelectTabs} onSelect={this.handleSelectTabs}
selected={currentTab.key}
selected={selectedTab.key}
tabs={tabs} tabs={tabs}
/> />
</div> </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);

+ 1
- 4
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

issue.transition.resetastoreview=Reset as To Review issue.transition.resetastoreview=Reset as To Review
issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again
issue.tabs.code=Where is the issue? 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=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 vulnerability.transition.resetastoreview.description=The vulnerability can't be fixed as is and needs more details. The security hotspot needs to be reviewed again
coding_rules.description_section.title.introduction=Introduction 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=Why is this an issue?
coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT=What is the risk? 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.how_to_fix=How can I fix it?
coding_rules.description_section.title.more_info=More Info coding_rules.description_section.title.more_info=More Info



Loading…
Cancel
Save