*/
import { cloneDeep, countBy, pick, trim } from 'lodash';
import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
-import { mockQualityProfile, mockRuleDetails, mockRuleRepository } from '../../helpers/testMocks';
+import {
+ mockCurrentUser,
+ mockQualityProfile,
+ mockRuleDetails,
+ mockRuleRepository
+} from '../../helpers/testMocks';
import { RuleRepository } from '../../types/coding-rules';
import { RawIssuesResponse } from '../../types/issues';
import { SearchRulesQuery } from '../../types/rules';
import { Rule, RuleActivation, RuleDetails, RulesUpdateRequest } from '../../types/types';
+import { NoticeType } from '../../types/users';
import { getFacet } from '../issues';
import {
bulkActivateRules,
SearchQualityProfilesResponse
} from '../quality-profiles';
import { getRuleDetails, getRulesApp, searchRules, updateRule } from '../rules';
+import { dismissNotification, getCurrentUser } from '../users';
interface FacetFilter {
languages?: string;
repositories: RuleRepository[] = [];
isAdmin = false;
applyWithWarning = false;
+ dismissedNoticesEP = false;
constructor() {
this.repositories = [
];
const resourceContent = 'Some link <a href="http://example.com">Awsome Reading</a>';
+ const introTitle = 'Introduction to this rule';
+ const rootCauseContent = 'This how to fix';
this.defaultRules = [
mockRuleDetails({
type: 'SECURITY_HOTSPOT',
lang: 'js',
descriptionSections: [
- { key: RuleDescriptionSections.INTRODUCTION, content: 'Introduction to this rule' },
- { key: RuleDescriptionSections.ROOT_CAUSE, content: 'This how to fix' },
+ { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+ { key: RuleDescriptionSections.ROOT_CAUSE, content: rootCauseContent },
{ key: RuleDescriptionSections.ASSESS_THE_PROBLEM, content: 'Assess' },
{
key: RuleDescriptionSections.RESOURCES,
langName: 'Python',
name: 'Awsome Python rule',
descriptionSections: [
- { key: RuleDescriptionSections.INTRODUCTION, content: 'Introduction to this rule' },
- { key: RuleDescriptionSections.HOW_TO_FIX, content: 'This how to fix' },
+ { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+ { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
{
key: RuleDescriptionSections.RESOURCES,
content: resourceContent
content: resourceContent
}
]
+ }),
+ mockRuleDetails({
+ key: 'rule8',
+ type: 'VULNERABILITY',
+ lang: 'py',
+ langName: 'Python',
+ name: 'Awesome Python rule with education principles',
+ descriptionSections: [
+ { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+ { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
+ {
+ key: RuleDescriptionSections.RESOURCES,
+ content: resourceContent
+ }
+ ],
+ educationPrinciples: ['defense_in_depth', 'least_trust_principle']
})
];
(bulkActivateRules as jest.Mock).mockImplementation(this.handleBulkActivateRules);
(bulkDeactivateRules as jest.Mock).mockImplementation(this.handleBulkDeactivateRules);
(getFacet as jest.Mock).mockImplementation(this.handleGetGacet);
-
+ (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
+ (dismissNotification as jest.Mock).mockImplementation(this.handleDismissNotification);
this.rules = cloneDeep(this.defaultRules);
}
reset() {
this.isAdmin = false;
this.applyWithWarning = false;
+ this.dismissedNoticesEP = false;
this.rules = cloneDeep(this.defaultRules);
}
return this.reply({ canWrite: this.isAdmin, repositories: this.repositories });
};
+ handleGetCurrentUser = () => {
+ return this.reply(
+ mockCurrentUser({
+ dismissedNotices: {
+ educationPrinciples: this.dismissedNoticesEP
+ }
+ })
+ );
+ };
+
+ handleDismissNotification = (noticeType: NoticeType) => {
+ if (noticeType === NoticeType.EDUCATION_PRINCIPLES) {
+ this.dismissedNoticesEP = true;
+ return this.reply(true);
+ }
+
+ return Promise.reject();
+ };
+
reply<T>(response: T): Promise<T> {
return Promise.resolve(cloneDeep(response));
}
} from '../../helpers/mocks/sources';
import { RequestData } from '../../helpers/request';
import { getStandards } from '../../helpers/security-standard';
-import { mockPaging, mockRawIssue, mockRuleDetails } from '../../helpers/testMocks';
+import {
+ mockCurrentUser,
+ mockPaging,
+ mockRawIssue,
+ mockRuleDetails
+} from '../../helpers/testMocks';
import { BranchParameters } from '../../types/branch-like';
import { RawFacet, RawIssue, RawIssuesResponse, ReferencedComponent } from '../../types/issues';
import { Standards } from '../../types/security';
SnippetsByComponent,
SourceViewerFile
} from '../../types/types';
+import { NoticeType } from '../../types/users';
import { getComponentForSourceViewer, getSources } from '../components';
import { getIssueFlowSnippets, searchIssues } from '../issues';
import { getRuleDetails } from '../rules';
+import { dismissNotification, getCurrentUser } from '../users';
function mockReferenceComponent(override?: Partial<ReferencedComponent>) {
return {
(getComponentForSourceViewer as jest.Mock).mockImplementation(
this.handleGetComponentForSourceViewer
);
+ (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
+ (dismissNotification as jest.Mock).mockImplementation(this.handleDismissNotification);
}
async getStandards(): Promise<Standards> {
});
};
+ handleGetCurrentUser = () => {
+ return this.reply(mockCurrentUser());
+ };
+
+ handleDismissNotification = (noticeType: NoticeType) => {
+ if (noticeType === NoticeType.EDUCATION_PRINCIPLES) {
+ return this.reply(true);
+ }
+
+ return Promise.reject();
+ };
+
reply<T>(response: T): Promise<T> {
return Promise.resolve(cloneDeep(response));
}
import { throwGlobalError } from '../helpers/error';
import { getJSON, post, postJSON } from '../helpers/request';
import { IdentityProvider, Paging } from '../types/types';
-import { CurrentUser, HomePage, User } from '../types/users';
+import { CurrentUser, HomePage, NoticeType, User } from '../types/users';
export function getCurrentUser(): Promise<CurrentUser> {
return getJSON('/api/users/current');
}
+export function dismissNotification(notice: NoticeType) {
+ return post('/api/users/dismiss_notice', { notice }).catch(throwGlobalError);
+}
+
export function changePassword(data: {
login: string;
password: string;
isLoggedIn: true,
login: 'luke',
name: 'Skywalker',
- scmAccounts: []
+ scmAccounts: [],
+ dismissedNotices: {}
};
beforeEach(() => {
onPasswordChange={[Function]}
user={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
export default class CurrentUserContextProvider extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
- this.state = { currentUser: props.currentUser ?? { isLoggedIn: false } };
+ this.state = { currentUser: props.currentUser ?? { isLoggedIn: false, dismissedNotices: {} } };
}
updateCurrentUserHomepage = (homepage: HomePage) => {
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
});
it('should render correctly when the user is not logged in', () => {
- const wrapper = shallowRender({ currentUser: { isLoggedIn: false } });
+ const wrapper = shallowRender({ currentUser: { isLoggedIn: false, dismissedNotices: {} } });
expect(wrapper.find(HomePageSelect).exists()).toBe(false);
});
});
function shallowRender(props: Partial<GlobalNavProps> = {}) {
- return shallow(<GlobalNav currentUser={{ isLoggedIn: false }} location={location} {...props} />);
+ return shallow(
+ <GlobalNav
+ currentUser={{ isLoggedIn: false, dismissedNotices: {} }}
+ location={location}
+ {...props}
+ />
+ );
}
});
const currentUser = {
- isLoggedIn: false
+ isLoggedIn: false,
+ dismissedNotices: {}
};
renderGlobalNavMenu({ appState, currentUser });
expect(screen.getByText('more')).toBeInTheDocument();
qualifiers: ['TRK']
});
const currentUser = {
- isLoggedIn: false
+ isLoggedIn: false,
+ dismissedNotices: {}
};
renderGlobalNavMenu({ appState, currentUser });
<withAppStateContext(GlobalNavMenu)
currentUser={
Object {
+ "dismissedNotices": Object {},
"isLoggedIn": false,
}
}
<withRouter(GlobalNavUser)
currentUser={
Object {
+ "dismissedNotices": Object {},
"isLoggedIn": false,
}
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { screen, waitFor, within } from '@testing-library/react';
+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';
jest.mock('../../../api/rules');
jest.mock('../../../api/issues');
+jest.mock('../../../api/users');
jest.mock('../../../api/quality-profiles');
let handler: CodingRulesMock;
expect(screen.getByText('x_of_y_shown.3.3')).toBeInTheDocument();
});
+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');
+ await screen.findByRole('heading', {
+ level: 3,
+ name: 'Awesome Python rule with education principles'
+ });
+ 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.getByText('coding_rules.more_info.notification_message')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ name: 'coding_rules.more_info.scroll_message'
+ })
+ ).toBeInTheDocument();
+ await user.click(
+ screen.getByRole('button', {
+ name: 'coding_rules.more_info.scroll_message'
+ })
+ );
+ // navigate away and come back
+ await user.click(
+ screen.getByRole('button', {
+ name: 'coding_rules.description_section.title.how_to_fix'
+ })
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: 'coding_rules.description_section.title.more_info'
+ })
+ );
+ expect(screen.queryByText('coding_rules.more_info.notification_message')).not.toBeInTheDocument();
+});
+
+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');
+ await screen.findByRole('heading', {
+ level: 3,
+ name: 'Awesome Python rule with education principles'
+ });
+ expect(
+ screen.getByRole('button', {
+ name: 'coding_rules.description_section.title.more_info'
+ })
+ ).toBeInTheDocument();
+
+ // navigate away and come back
+ await user.click(
+ screen.getByRole('button', {
+ name: 'coding_rules.description_section.title.how_to_fix'
+ })
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: 'coding_rules.description_section.title.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.getByText('coding_rules.more_info.notification_message')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ 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', {
+ name: 'coding_rules.description_section.title.how_to_fix'
+ })
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: 'coding_rules.description_section.title.more_info'
+ })
+ );
+ expect(screen.queryByText('coding_rules.more_info.notification_message')).not.toBeInTheDocument();
+});
+
function renderCodingRulesApp(currentUser?: CurrentUser, navigateTo?: string) {
renderApp('coding_rules', routes, {
navigateTo,
*/
import { groupBy } from 'lodash';
import * as React from 'react';
-import BoxedTabs from '../../../components/controls/BoxedTabs';
-import MoreInfoRuleDescription from '../../../components/rules/MoreInfoRuleDescription';
import RuleDescription from '../../../components/rules/RuleDescription';
+import TabViewer, {
+ getHowToFixTab,
+ getMoreInfoTab,
+ getWhyIsThisAnIssueTab,
+ Tab,
+ TabKeys
+} from '../../../components/rules/TabViewer';
import { translate } from '../../../helpers/l10n';
import { sanitizeString } from '../../../helpers/sanitize';
import { RuleDetails } from '../../../types/types';
ruleDetails: RuleDetails;
}
-interface State {
- currentTab: Tab;
- tabs: Tab[];
-}
-
-interface Tab {
- key: RuleTabKeys;
- label: React.ReactNode;
- content: React.ReactNode;
-}
-
-enum RuleTabKeys {
- WhyIsThisAnIssue = 'why',
- HowToFixIt = 'how_to_fix',
- AssessTheIssue = 'assess_the_problem',
- MoreInfo = 'more_info'
-}
-
-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: RuleTabKeys) => {
- this.setState(({ tabs }) => ({
- currentTab: tabs.find(tab => tab.key === currentTabKey) || tabs[0]
- }));
- };
-
- computeState() {
+export default class RuleViewerTabs 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;
- const tabs = [
- {
- key: RuleTabKeys.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: descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE] && (
- <RuleDescription
- sections={descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]}
- />
- )
- },
+ 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: RuleTabKeys.AssessTheIssue,
- label: translate('coding_rules.description_section.title', RuleTabKeys.AssessTheIssue),
+ key: TabKeys.AssessTheIssue,
+ label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue),
content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
<RuleDescription
sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
/>
)
},
- {
- key: RuleTabKeys.HowToFixIt,
- label: translate('coding_rules.description_section.title', RuleTabKeys.HowToFixIt),
- content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
- <RuleDescription
- sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
- />
- )
- },
- {
- key: RuleTabKeys.MoreInfo,
- label: translate('coding_rules.description_section.title', RuleTabKeys.MoreInfo),
- content: (ruleDetails.educationPrinciples ||
- descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && (
- <MoreInfoRuleDescription
- educationPrinciples={ruleDetails.educationPrinciples}
- sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]}
- />
- )
- }
+ 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>;
-
- 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;
dangerouslySetInnerHTML={{ __html: sanitizeString(intro) }}
/>
)}
- <BoxedTabs
- className="bordered-bottom big-spacer-top"
- onSelect={this.handleSelectTabs}
- selected={currentTab.key}
- tabs={tabs}
- />
-
- <div className="bordered-right bordered-left bordered-bottom huge-spacer-bottom">
- {currentTab.content}
- </div>
+ <TabViewer ruleDetails={this.props.ruleDetails} computeTabs={this.computeTabs} />
</>
);
}
.rules-context-description h2.rule-contexts-title {
border: 0px;
}
+
+.notice-dot {
+ height: var(--gridSize);
+ width: var(--gridSize);
+ background-color: var(--blue);
+ border-radius: 50%;
+ display: inline-block;
+ margin-left: var(--gridSize);
+}
jest.mock('../../../api/issues');
jest.mock('../../../api/rules');
jest.mock('../../../api/components');
+jest.mock('../../../api/users');
let handler: IssuesServiceMock;
expect(screen.getByRole('heading', { name: 'Because' })).toBeInTheDocument();
// Select the "how to fix it" tab
- expect(screen.getByRole('button', { name: `issue.tabs.how` })).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: `issue.tabs.how` }));
+ expect(screen.getByRole('button', { name: `issue.tabs.how_to_fix` })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: `issue.tabs.how_to_fix` }));
// Is the context selector present with the expected values and default selection?
expect(screen.getByRole('radio', { name: 'Context 2' })).toBeInTheDocument();
import { groupBy } from 'lodash';
import * as React from 'react';
import { Link } from 'react-router-dom';
-import BoxedTabs from '../../../components/controls/BoxedTabs';
-import MoreInfoRuleDescription from '../../../components/rules/MoreInfoRuleDescription';
-import RuleDescription from '../../../components/rules/RuleDescription';
+import TabViewer, {
+ getHowToFixTab,
+ getMoreInfoTab,
+ getWhyIsThisAnIssueTab,
+ Tab,
+ TabKeys
+} from '../../../components/rules/TabViewer';
import { translate } from '../../../helpers/l10n';
import { getRuleUrl } from '../../../helpers/urls';
import { Component, Issue, RuleDetails } from '../../../types/types';
ruleDetails: RuleDetails;
}
-interface State {
- currentTabKey: IssueTabKeys;
- tabs: Tab[];
-}
-
-interface Tab {
- key: IssueTabKeys;
- label: React.ReactNode;
- content: React.ReactNode;
-}
-
-enum IssueTabKeys {
- Code = 'code',
- WhyIsThisAnIssue = 'why',
- HowToFixIt = 'how',
- MoreInfo = 'more_info'
-}
-
-export default class IssueViewerTabs extends React.PureComponent<Props, State> {
- constructor(props: Props) {
- super(props);
- const tabs = this.computeTabs();
- this.state = {
- currentTabKey: tabs[0].key,
- tabs
- };
- }
-
- componentDidUpdate(prevProps: Props) {
- if (
- prevProps.ruleDetails !== this.props.ruleDetails ||
- prevProps.codeTabContent !== this.props.codeTabContent
- ) {
- const tabs = this.computeTabs();
- this.setState({
- currentTabKey: tabs[0].key,
- tabs
- });
- }
- }
-
- handleSelectTabs = (currentTabKey: IssueTabKeys) => {
- this.setState({ currentTabKey });
- };
-
- computeTabs() {
+export default class IssueViewerTabs extends React.PureComponent<Props> {
+ computeTabs = (showNotice: boolean, educationPrinciplesRef: React.RefObject<HTMLDivElement>) => {
const {
ruleDetails,
codeTabContent,
ruleDetails.descriptionSections,
section => section.key
);
+ const hasEducationPrinciples =
+ !!ruleDetails.educationPrinciples && ruleDetails.educationPrinciples.length > 0;
+ const showNotification = showNotice && hasEducationPrinciples;
if (ruleDetails.htmlNote) {
if (descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] !== undefined) {
return [
{
- key: IssueTabKeys.Code,
- label: translate('issue.tabs', IssueTabKeys.Code),
+ key: TabKeys.Code,
+ label: translate('issue.tabs', TabKeys.Code),
content: <div className="padded">{codeTabContent}</div>
},
- {
- key: IssueTabKeys.WhyIsThisAnIssue,
- label: translate('issue.tabs', IssueTabKeys.WhyIsThisAnIssue),
- content: rootCauseDescriptionSections && (
- <RuleDescription
- sections={rootCauseDescriptionSections}
- isDefault={descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] !== undefined}
- defaultContextKey={ruleDescriptionContextKey}
- />
- )
- },
- {
- key: IssueTabKeys.HowToFixIt,
- label: translate('issue.tabs', IssueTabKeys.HowToFixIt),
- content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
- <RuleDescription
- sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
- defaultContextKey={ruleDescriptionContextKey}
- />
- )
- },
- {
- key: IssueTabKeys.MoreInfo,
- label: translate('issue.tabs', IssueTabKeys.MoreInfo),
- content: (ruleDetails.educationPrinciples ||
- descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && (
- <MoreInfoRuleDescription
- educationPrinciples={ruleDetails.educationPrinciples}
- sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]}
- />
- )
- }
+ 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;
- const { tabs, currentTabKey } = this.state;
- const selectedTab = tabs.find(tab => tab.key === currentTabKey);
return (
<>
<div
{key}
</Link>
</div>
- <BoxedTabs
- className="bordered-bottom"
- onSelect={this.handleSelectTabs}
- selected={currentTabKey}
- tabs={tabs}
- />
</div>
- {selectedTab && (
- <div className="bordered-right bordered-left bordered-bottom huge-spacer-bottom">
- {selectedTab.content}
- </div>
- )}
+ <TabViewer
+ ruleDetails={ruleDetails}
+ computeTabs={this.computeTabs}
+ codeTabContent={codeTabContent}
+ pageType="issues"
+ />
</>
);
}
return shallow<BulkChangeModal>(
<BulkChangeModal
component={undefined}
- currentUser={{ isLoggedIn: true }}
+ currentUser={{ isLoggedIn: true, dismissedNotices: {} }}
fetchIssues={() =>
Promise.resolve({
issues,
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';
getRuleDetails: jest.fn()
}));
+jest.mock('../../../../api/users', () => ({
+ getCurrentUser: jest.fn().mockResolvedValue({
+ dismissedNotices: {
+ something: false
+ }
+ }),
+ dismissNotification: jest.fn()
+}));
+
const RAW_ISSUES = [
mockRawIssue(false, { key: 'foo' }),
mockRawIssue(false, { key: 'bar' }),
wrapper
.find(IssueViewerTabs)
.dive()
+ .find(TabViewer)
+ .dive()
.find(IssuesSourceViewer)
.exists()
).toBe(true);
top: 48px;
background-color: white;
padding-top: 20px;
+ height: 50px;
}
.issue-project-level.issue-header {
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
) {
const wrapper = shallow<AllProjects>(
<AllProjects
- currentUser={{ isLoggedIn: true }}
+ currentUser={{ isLoggedIn: true, dismissedNotices: {} }}
isFavorite={false}
location={mockLocation({ pathname: '/projects', query: {} })}
appState={mockAppState({
it('renders correctly for SQ', () => {
expect(
- shallow(<EmptyInstance currentUser={{ isLoggedIn: false }} router={mockRouter()} />)
+ shallow(
+ <EmptyInstance
+ currentUser={{ isLoggedIn: false, dismissedNotices: {} }}
+ router={mockRouter()}
+ />
+ )
).toMatchSnapshot();
expect(
shallow(
<EmptyInstance
- currentUser={{ isLoggedIn: true, permissions: { global: ['provisioning'] } }}
+ currentUser={{
+ isLoggedIn: true,
+ permissions: { global: ['provisioning'] },
+ dismissedNotices: {}
+ }}
router={mockRouter()}
/>
)
function shallowRender(props?: {}) {
return shallow(
<PageHeader
- currentUser={{ isLoggedIn: false }}
+ currentUser={{ isLoggedIn: false, dismissedNotices: {} }}
loading={false}
onPerspectiveChange={jest.fn()}
onQueryChange={jest.fn()}
<PageHeader
currentUser={
Object {
+ "dismissedNotices": Object {},
"isLoggedIn": true,
}
}
cardType="overall"
currentUser={
Object {
+ "dismissedNotices": Object {},
"isLoggedIn": true,
}
}
<RestoreAccessModal
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"isLoggedIn": false,
}
}
allowCurrentUserSelection={false}
loggedInUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
import { Location, Router, withRouter } from '../../components/hoc/withRouter';
import { translate } from '../../helpers/l10n';
import { IdentityProvider, Paging } from '../../types/types';
-import { User } from '../../types/users';
+import { CurrentUser, User } from '../../types/users';
import Header from './Header';
import Search from './Search';
import UsersList from './UsersList';
import { parseQuery, Query, serializeQuery } from './utils';
interface Props {
- currentUser: { isLoggedIn: boolean; login?: string };
+ currentUser: CurrentUser;
location: Location;
router: Router;
}
const getIdentityProviders = require('../../../api/users').getIdentityProviders as jest.Mock<any>;
const searchUsers = require('../../../api/users').searchUsers as jest.Mock<any>;
-const currentUser = { isLoggedIn: true, login: 'luke' };
+const currentUser = { isLoggedIn: true, login: 'luke', dismissedNotices: {} };
const location = { pathname: '', query: {} } as Location;
beforeEach(() => {
<UsersList
currentUser={
Object {
+ "dismissedNotices": Object {},
"isLoggedIn": true,
"login": "luke",
}
<UsersList
currentUser={
Object {
+ "dismissedNotices": Object {},
"isLoggedIn": true,
"login": "luke",
}
return shallow(
<CurrentUserContext.Provider
value={{
- currentUser: { isLoggedIn },
+ currentUser: { isLoggedIn, dismissedNotices: {} },
updateCurrentUserHomepage: () => {},
updateCurrentUserSonarLintAdSeen: () => {}
}}>
import * as React from 'react';
import { RuleDescriptionSection } from '../../apps/coding-rules/rule';
import { translate } from '../../helpers/l10n';
+import { scrollToElement } from '../../helpers/scrolling';
import { Dict } from '../../types/types';
+import { ButtonLink } from '../controls/buttons';
+import { Alert } from '../ui/Alert';
import DefenseInDepth from './educationPrinciples/DefenseInDepth';
import LeastTrustPrinciple from './educationPrinciples/LeastTrustPrinciple';
import RuleDescription from './RuleDescription';
interface Props {
sections?: RuleDescriptionSection[];
educationPrinciples?: string[];
+ showNotification?: boolean;
+ educationPrinciplesRef?: React.RefObject<HTMLDivElement>;
}
const EDUCATION_PRINCIPLES_MAP: Dict<React.ComponentType> = {
defense_in_depth: DefenseInDepth,
least_trust_principle: LeastTrustPrinciple
};
+export default class MoreInfoRuleDescription extends React.PureComponent<Props, {}> {
+ handleNotificationScroll = () => {
+ const element = this.props.educationPrinciplesRef?.current;
+ if (element) {
+ scrollToElement(element, { topOffset: 20, bottomOffset: 250 });
+ }
+ };
-export default function MoreInfoRuleDescription({
- sections = [],
- educationPrinciples = []
-}: Props) {
- return (
- <>
- {sections.length > 0 && (
- <>
- <div className="big-padded-left big-padded-right big-padded-top rule-desc">
- <h2 className="null-spacer-bottom">
- {translate('coding_rules.more_info.resources.title')}
- </h2>
+ render() {
+ const { showNotification, sections = [], educationPrinciples = [] } = this.props;
+ return (
+ <>
+ {showNotification && (
+ <div className="big-padded-top big-padded-left big-padded-right rule-desc info-message">
+ <Alert variant="info">
+ <p className="little-spacer-bottom little-spacer-top">
+ {translate('coding_rules.more_info.notification_message')}
+ </p>
+ <ButtonLink
+ onClick={() => {
+ this.handleNotificationScroll();
+ }}>
+ {translate('coding_rules.more_info.scroll_message')}
+ </ButtonLink>
+ </Alert>
</div>
- <RuleDescription key="more-info" sections={sections} />
- </>
- )}
+ )}
+ {sections.length > 0 && (
+ <>
+ <div className="big-padded-left big-padded-right big-padded-top rule-desc">
+ <h2 className="null-spacer-bottom">
+ {translate('coding_rules.more_info.resources.title')}
+ </h2>
+ </div>
+ <RuleDescription key="more-info" sections={sections} />
+ </>
+ )}
- {educationPrinciples.length > 0 && (
- <>
- <div className="big-padded-left big-padded-right rule-desc">
- <h2 className="null-spacer-top">
- {translate('coding_rules.more_info.education_principles.title')}
- </h2>
- </div>
- {educationPrinciples.map(key => {
- const Concept = EDUCATION_PRINCIPLES_MAP[key];
- if (Concept === undefined) {
- return null;
- }
- return (
- <div key={key} className="education-principles rule-desc">
- <Concept />
- </div>
- );
- })}
- </>
- )}
- </>
- );
+ {educationPrinciples.length > 0 && (
+ <>
+ <div className="big-padded-left big-padded-right rule-desc">
+ <h2 ref={this.props.educationPrinciplesRef} className="null-spacer-top">
+ {translate('coding_rules.more_info.education_principles.title')}
+ </h2>
+ </div>
+ {educationPrinciples.map(key => {
+ const Concept = EDUCATION_PRINCIPLES_MAP[key];
+ if (Concept === undefined) {
+ return null;
+ }
+ return (
+ <div key={key} className="education-principles rule-desc">
+ <Concept />
+ </div>
+ );
+ })}
+ </>
+ )}
+ </>
+ );
+ }
}
--- /dev/null
+/*
+ * 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 classNames from 'classnames';
+import { debounce, Dictionary } from 'lodash';
+import * as React from 'react';
+import { dismissNotification, getCurrentUser } from '../../api/users';
+import { RuleDescriptionSection, RuleDescriptionSections } from '../../apps/coding-rules/rule';
+import { RuleDetails } from '../../types/types';
+import { CurrentUser, NoticeType } from '../../types/users';
+import BoxedTabs from '../controls/BoxedTabs';
+import MoreInfoRuleDescription from './MoreInfoRuleDescription';
+import RuleDescription from './RuleDescription';
+import './style.css';
+
+interface Props {
+ ruleDetails: RuleDetails;
+ codeTabContent?: React.ReactNode;
+ computeTabs: (
+ showNotice: boolean,
+ educationPrinciplesRef: React.RefObject<HTMLDivElement>
+ ) => Tab[];
+ pageType?: string;
+}
+
+interface State {
+ currentTab: Tab;
+ tabs: Tab[];
+}
+
+export interface Tab {
+ key: TabKeys;
+ label: React.ReactNode;
+ content: React.ReactNode;
+}
+
+export enum TabKeys {
+ Code = 'code',
+ WhyIsThisAnIssue = 'why',
+ HowToFixIt = 'how_to_fix',
+ AssessTheIssue = 'assess_the_problem',
+ MoreInfo = 'more_info'
+}
+
+const DEBOUNCE_FOR_SCROLL = 250;
+
+export default class TabViewer extends React.PureComponent<Props, State> {
+ showNotification = false;
+ educationPrinciplesRef: React.RefObject<HTMLDivElement>;
+
+ constructor(props: Props) {
+ 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);
+ }
+
+ componentDidMount() {
+ this.getNotificationValue();
+ }
+
+ componentDidUpdate(prevProps: Props, prevState: State) {
+ const { currentTab } = this.state;
+ if (
+ prevProps.ruleDetails !== this.props.ruleDetails ||
+ prevProps.codeTabContent !== this.props.codeTabContent
+ ) {
+ const tabs = this.getUpdatedTabs(this.showNotification);
+ this.getNotificationValue();
+ this.setState({
+ tabs,
+ currentTab: tabs[0]
+ });
+ }
+ if (currentTab.key === TabKeys.MoreInfo) {
+ this.checkIfConceptIsVisible();
+ }
+
+ if (prevState.currentTab.key === TabKeys.MoreInfo && !this.showNotification) {
+ const tabs = this.getUpdatedTabs(this.showNotification);
+ this.setState({ tabs });
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('scroll', this.checkIfConceptIsVisible);
+ }
+
+ checkIfConceptIsVisible = () => {
+ if (this.educationPrinciplesRef.current) {
+ const rect = this.educationPrinciplesRef.current.getBoundingClientRect();
+ const isView = rect.top <= (window.innerHeight || document.documentElement.clientHeight);
+ if (isView && this.showNotification) {
+ dismissNotification(NoticeType.EDUCATION_PRINCIPLES)
+ .then(() => {
+ document.removeEventListener('scroll', this.checkIfConceptIsVisible);
+ this.showNotification = false;
+ })
+ .catch(() => {
+ /* 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) => {
+ this.setState(({ tabs }) => ({
+ currentTab: 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 { pageType } = this.props;
+ return (
+ <>
+ <div
+ className={classNames({
+ 'tab-view-header': pageType === 'issues'
+ })}>
+ <BoxedTabs
+ className="bordered-bottom big-spacer-top"
+ onSelect={this.handleSelectTabs}
+ selected={currentTab.key}
+ tabs={tabs}
+ />
+ </div>
+ <div className="bordered-right bordered-left bordered-bottom huge-spacer-bottom">
+ {currentTab.content}
+ </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
+ 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
+ sections={rootCauseDescriptionSections}
+ isDefault={descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] !== undefined}
+ defaultContextKey={ruleDescriptionContextKey}
+ />
+ )
+ };
+};
padding-left: 16px;
padding-right: 16px;
}
+
+.tab-view-header {
+ z-index: 100;
+ position: sticky;
+ top: 118px;
+ background-color: white;
+ padding-top: 20px;
+}
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
}
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
<TokenStep
currentUser={
Object {
+ "dismissedNotices": Object {
+ "educationPrinciples": false,
+ },
"groups": Array [],
"isLoggedIn": true,
"login": "luke",
export function mockCurrentUser(overrides: Partial<CurrentUser> = {}): CurrentUser {
return {
isLoggedIn: false,
+ dismissedNotices: {
+ educationPrinciples: false
+ },
...overrides
};
}
login: 'luke',
name: 'Skywalker',
scmAccounts: [],
+ dismissedNotices: {
+ educationPrinciples: false
+ },
...overrides
};
}
isLoggedIn: boolean;
permissions?: { global: string[] };
usingSonarLintConnectedMode?: boolean;
+ dismissedNotices: { [key: string]: boolean };
+}
+
+export interface Notice {
+ key: NoticeType;
+ value: boolean;
+}
+
+export enum NoticeType {
+ EDUCATION_PRINCIPLES = 'educationPrinciples',
+ SONARLINT_AD_SEEN = 'sonarlint_ad_seen'
}
export interface LoggedInUser extends CurrentUser, UserActive {
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=How to fix it?
+issue.tabs.how_to_fix=How to fix it?
issue.tabs.more_info=More Info
vulnerability.transition.resetastoreview=Reset as To Review
coding_rules.more_info.education_principles.title=Security principles
coding_rules.more_info.resources.title=Resources
+coding_rules.more_info.notification_message=We've added new information about security principles below. Security principles are general guidelines that can help you improve the security of your code. Take a moment now to read through them.
+coding_rules.more_info.scroll_message=Scroll down to security principles
#------------------------------------------------------------------------------
#
# EMAIL CONFIGURATION