diff options
author | Philippe Perrin <philippe.perrin@sonarsource.com> | 2022-07-22 16:54:17 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-07-27 20:03:20 +0000 |
commit | 015d68fb1cdcb3c1e0fbce3cb725369a449c8003 (patch) | |
tree | 15df75f075912f788794cb59ffcdfb1c035e95ef /server | |
parent | 50925139d1437dd46cdbd906289674d3aa0c29a6 (diff) | |
download | sonarqube-015d68fb1cdcb3c1e0fbce3cb725369a449c8003.tar.gz sonarqube-015d68fb1cdcb3c1e0fbce3cb725369a449c8003.zip |
SONAR-16599 Use withCurrentUserContext + refactoring
Diffstat (limited to 'server')
8 files changed, 326 insertions, 364 deletions
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts index d723bab2c37..81ad6fa1d73 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts +++ b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts @@ -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, diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleTabViewer.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleTabViewer.tsx index 6c39abe92f7..3a5c4b2d1b4 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleTabViewer.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleTabViewer.tsx @@ -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} /> + </> + ); } diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx index ddabf99750b..8df2ddb0af6 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx @@ -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) { diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx index 578c7faec8c..01112b42f09 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx @@ -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" + /> + </> + ); } diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 49dc85b3a96..79f773c9221 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -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(); diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx index 62a1983a216..77a3df3eb33 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx @@ -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'); diff --git a/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx b/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx index ef9cc3b4b3c..dc599849bb4 100644 --- a/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx +++ b/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx @@ -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 => { diff --git a/server/sonar-web/src/main/js/components/rules/TabViewer.tsx b/server/sonar-web/src/main/js/components/rules/TabViewer.tsx index df0c83d593c..22651ca8b75 100644 --- a/server/sonar-web/src/main/js/components/rules/TabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/TabViewer.tsx @@ -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); |