]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16599 Displaying notification in rules more info tab
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Thu, 7 Jul 2022 11:48:39 +0000 (13:48 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 13 Jul 2022 20:03:05 +0000 (20:03 +0000)
48 files changed:
server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx
server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/ResetPassword-test.tsx.snap
server/sonar-web/src/main/js/app/components/current-user/CurrentUserContextProvider.tsx
server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap
server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts
server/sonar-web/src/main/js/apps/coding-rules/components/RuleTabViewer.tsx
server/sonar-web/src/main/js/apps/coding-rules/styles.css
server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx
server/sonar-web/src/main/js/apps/issues/styles.css
server/sonar-web/src/main/js/apps/overview/components/__tests__/__snapshots__/EmptyOverview-test.tsx.snap
server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/EmptyInstance-test.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/PageHeader-test.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/AllProjects-test.tsx.snap
server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ProjectRowActions-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/tutorials/components/__tests__/__snapshots__/TutorialsApp-test.tsx.snap
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-test.tsx
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap
server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx
server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx
server/sonar-web/src/main/js/components/rules/TabViewer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/rules/style.css
server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/azure-pipelines/__tests__/__snapshots__/AzurePipelinesTutorial-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/BitbucketPipelinesTutorial-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/RepositoryVariables-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/GitHubActionTutorial-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/SecretStep-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/EnvironmentVariablesStep-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/GitLabCITutorial-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap
server/sonar-web/src/main/js/helpers/testMocks.ts
server/sonar-web/src/main/js/types/users.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index b5ad81cb25640bb6e7a3bda46b247497e5733a92..f23c2913c7cedaa4aa20d175dcd37f1435b7df8e 100644 (file)
  */
 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,
@@ -34,6 +40,7 @@ import {
   SearchQualityProfilesResponse
 } from '../quality-profiles';
 import { getRuleDetails, getRulesApp, searchRules, updateRule } from '../rules';
+import { dismissNotification, getCurrentUser } from '../users';
 
 interface FacetFilter {
   languages?: string;
@@ -50,6 +57,7 @@ export default class CodingRulesMock {
   repositories: RuleRepository[] = [];
   isAdmin = false;
   applyWithWarning = false;
+  dismissedNoticesEP = false;
 
   constructor() {
     this.repositories = [
@@ -63,6 +71,8 @@ export default class CodingRulesMock {
     ];
 
     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({
@@ -78,8 +88,8 @@ export default class CodingRulesMock {
         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,
@@ -103,8 +113,8 @@ export default class CodingRulesMock {
         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
@@ -146,6 +156,22 @@ export default class CodingRulesMock {
             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']
       })
     ];
 
@@ -157,7 +183,8 @@ export default class CodingRulesMock {
     (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);
   }
 
@@ -198,6 +225,7 @@ export default class CodingRulesMock {
   reset() {
     this.isAdmin = false;
     this.applyWithWarning = false;
+    this.dismissedNoticesEP = false;
     this.rules = cloneDeep(this.defaultRules);
   }
 
@@ -350,6 +378,25 @@ export default class CodingRulesMock {
     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));
   }
index 0e1fe6101434a7f5b452e66ffa52b6eee45ec07d..7b83314b8d39e50adae8c0938829bbe2897a8571 100644 (file)
@@ -26,7 +26,12 @@ import {
 } 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';
@@ -37,9 +42,11 @@ import {
   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 {
@@ -192,6 +199,8 @@ export default class IssuesServiceMock {
     (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> {
@@ -314,6 +323,18 @@ export default class IssuesServiceMock {
     });
   };
 
+  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));
   }
index 9b94961ebc87f4a7cad678ad8cfc53f7a990c3ba..e512718770e1fce061af9b559c612730f038541b 100644 (file)
 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;
index 96a80a3e0af378993d120c808eea9f60b8cf1bb0..4d47866e21e06396e0473fb13df77a44d4337ff8 100644 (file)
@@ -55,7 +55,8 @@ const LOGGED_IN_USER: LoggedInUser = {
   isLoggedIn: true,
   login: 'luke',
   name: 'Skywalker',
-  scmAccounts: []
+  scmAccounts: [],
+  dismissedNotices: {}
 };
 
 beforeEach(() => {
index e6107f27865b11de71cbb883f4dccbfb30fca02c..b8f17c6c10a634f1b505f3bd2f6f9fc300f4181b 100644 (file)
@@ -29,6 +29,9 @@ exports[`should render correctly 1`] = `
         onPasswordChange={[Function]}
         user={
           Object {
+            "dismissedNotices": Object {
+              "educationPrinciples": false,
+            },
             "groups": Array [],
             "isLoggedIn": true,
             "login": "luke",
index 9ffe3d1ec59c3b2826400dba936dbf65810b0d4c..fb70ec97b919f065bfb09e782a3ebdea3ad5e00e 100644 (file)
@@ -32,7 +32,7 @@ interface State {
 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) => {
index 44929f62890f2c895146b00d54e22ccc2af00081..83c71e6d3121bf4e992e60d68807803fd4f1a2db 100644 (file)
@@ -15,6 +15,9 @@ exports[`should render React extensions correctly 1`] = `
   }
   currentUser={
     Object {
+      "dismissedNotices": Object {
+        "educationPrinciples": false,
+      },
       "isLoggedIn": false,
     }
   }
@@ -74,6 +77,9 @@ exports[`should render React extensions correctly 2`] = `
   }
   currentUser={
     Object {
+      "dismissedNotices": Object {
+        "educationPrinciples": false,
+      },
       "isLoggedIn": false,
     }
   }
index 36cd81491a233ed503004aa273455d0a09ab9321..bc5ff09ba86e8c1321d4a63ed548104955254812 100644 (file)
@@ -62,7 +62,7 @@ it('should render correctly for a pull request', () => {
 });
 
 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);
 });
 
index d85531ebc4da36228a528051f1ad1e011ac56f48..d0a5d89f22440c7a579fc3bd8757bdc26e2a239f 100644 (file)
@@ -35,5 +35,11 @@ it('should render correctly', async () => {
 });
 
 function shallowRender(props: Partial<GlobalNavProps> = {}) {
-  return shallow(<GlobalNav currentUser={{ isLoggedIn: false }} location={location} {...props} />);
+  return shallow(
+    <GlobalNav
+      currentUser={{ isLoggedIn: false, dismissedNotices: {} }}
+      location={location}
+      {...props}
+    />
+  );
 }
index 5afe85b9f3500003f3ba12c267abc5b6725334e9..0beed7916cfbed4c0cde12b582bee9f98d6a05f1 100644 (file)
@@ -30,7 +30,8 @@ it('should work with extensions', () => {
   });
 
   const currentUser = {
-    isLoggedIn: false
+    isLoggedIn: false,
+    dismissedNotices: {}
   };
   renderGlobalNavMenu({ appState, currentUser });
   expect(screen.getByText('more')).toBeInTheDocument();
@@ -43,7 +44,8 @@ it('should show administration menu if the user has the rights', () => {
     qualifiers: ['TRK']
   });
   const currentUser = {
-    isLoggedIn: false
+    isLoggedIn: false,
+    dismissedNotices: {}
   };
 
   renderGlobalNavMenu({ appState, currentUser });
index c693f643ee95b0f810873f97a7b51ee7cc61886e..a6e536843001fbb05bf1ec4043775491ac145a52 100644 (file)
@@ -10,6 +10,7 @@ exports[`should render correctly: anonymous users 1`] = `
   <withAppStateContext(GlobalNavMenu)
     currentUser={
       Object {
+        "dismissedNotices": Object {},
         "isLoggedIn": false,
       }
     }
@@ -27,6 +28,7 @@ exports[`should render correctly: anonymous users 1`] = `
     <withRouter(GlobalNavUser)
       currentUser={
         Object {
+          "dismissedNotices": Object {},
           "isLoggedIn": false,
         }
       }
index 03d8a6a7a1a793cd5eccc4415ef01a9411c49f8d..e7265455589d49e80324b3d09058e6fa018caeb5 100644 (file)
@@ -17,7 +17,7 @@
  * 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';
@@ -27,6 +27,7 @@ import routes from '../routes';
 
 jest.mock('../../../api/rules');
 jest.mock('../../../api/issues');
+jest.mock('../../../api/users');
 jest.mock('../../../api/quality-profiles');
 
 let handler: CodingRulesMock;
@@ -374,6 +375,108 @@ it('should handle hash parameters', async () => {
   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,
index cebd8c1d4d8b9a295a86ef0be0003547e23345fd..4ad62d13211da41679f589642a11a99d4729e391 100644 (file)
  */
 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';
@@ -31,102 +36,53 @@ interface Props {
   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;
@@ -139,16 +95,7 @@ export default class RuleViewerTabs extends React.PureComponent<Props, State> {
             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} />
       </>
     );
   }
index 0c70cdd2fe6f348b506f0c06d19e1c398a081dbe..dcc291a700677314243534b618a2a4f5051e914e 100644 (file)
 .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);
+}
index ab7c94ca37b71132c553850967cfb446a7ff0829..aa9052cba31f951f9dbf791589e56f05dc26ce37 100644 (file)
@@ -30,6 +30,7 @@ import { projectIssuesRoutes } from '../routes';
 jest.mock('../../../api/issues');
 jest.mock('../../../api/rules');
 jest.mock('../../../api/components');
+jest.mock('../../../api/users');
 
 let handler: IssuesServiceMock;
 
@@ -64,8 +65,8 @@ it('should open issue and navigate', async () => {
   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();
index 005b6fdbe82c33211d1d08901dd5ffdd0d972485..52b264780b6972415d19e3c53452839125b6ffa6 100644 (file)
@@ -21,9 +21,13 @@ import classNames from 'classnames';
 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';
@@ -36,52 +40,8 @@ interface Props {
   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,
@@ -91,6 +51,9 @@ export default class IssueViewerTabs extends React.PureComponent<Props, State> {
       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) {
@@ -114,53 +77,38 @@ export default class IssueViewerTabs extends React.PureComponent<Props, State> {
 
     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
@@ -174,18 +122,13 @@ export default class IssueViewerTabs extends React.PureComponent<Props, State> {
               {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"
+        />
       </>
     );
   }
index 19c592dbb47d2778897532eca567270607d941c6..6e7899de8a8f784877a56f4f5c97f7db60f609f8 100644 (file)
@@ -111,7 +111,7 @@ const getWrapper = (issues: Issue[]) => {
   return shallow<BulkChangeModal>(
     <BulkChangeModal
       component={undefined}
-      currentUser={{ isLoggedIn: true }}
+      currentUser={{ isLoggedIn: true, dismissedNotices: {} }}
       fetchIssues={() =>
         Promise.resolve({
           issues,
index 3b035bb3258bb08843ec5c46e84becc03e16cd37..62a1983a2166894f5cf5c7ea2cb22a30f3252d4d 100644 (file)
@@ -21,6 +21,7 @@ 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';
@@ -73,6 +74,15 @@ jest.mock('../../../../api/rules', () => ({
   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' }),
@@ -210,6 +220,8 @@ it('should switch to source view if an issue is selected', async () => {
     wrapper
       .find(IssueViewerTabs)
       .dive()
+      .find(TabViewer)
+      .dive()
       .find(IssuesSourceViewer)
       .exists()
   ).toBe(true);
index 99fcc9ad4730d2c577976aa12f20de6554a1078c..00567cb0303b47b5c8816119f3a92948a4ed1853 100644 (file)
   top: 48px;
   background-color: white;
   padding-top: 20px;
+  height: 50px;
 }
 
 .issue-project-level.issue-header {
index 0feced6e7c721a871aad80610845542390e50d35..afd2b0d3b0cf144438e6efbca4369492b8e4ac83 100644 (file)
@@ -30,6 +30,9 @@ exports[`renders correctly 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
@@ -98,6 +101,9 @@ exports[`renders correctly 4`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
index 77d28e8727479a08e7a06eb15c231d920018bc2c..f574600c0735e71506466018d72bd9acadf1ef26 100644 (file)
@@ -152,7 +152,7 @@ function shallowRender(
 ) {
   const wrapper = shallow<AllProjects>(
     <AllProjects
-      currentUser={{ isLoggedIn: true }}
+      currentUser={{ isLoggedIn: true, dismissedNotices: {} }}
       isFavorite={false}
       location={mockLocation({ pathname: '/projects', query: {} })}
       appState={mockAppState({
index ccab51475d93b62877f981baf46e93768e216ef1..5264e1c064ede0ac20e2e0c411a9eb0ca3467d5a 100644 (file)
@@ -24,12 +24,21 @@ import { EmptyInstance } from '../EmptyInstance';
 
 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()}
       />
     )
index c50803a8740fbe0ddb0e380bf452b4747ca9a41b..afff1343f106a9236ce61aaac8e377dc15c9e233 100644 (file)
@@ -57,7 +57,7 @@ it('should render switch the default sorting option for anonymous users', () =>
 function shallowRender(props?: {}) {
   return shallow(
     <PageHeader
-      currentUser={{ isLoggedIn: false }}
+      currentUser={{ isLoggedIn: false, dismissedNotices: {} }}
       loading={false}
       onPerspectiveChange={jest.fn()}
       onQueryChange={jest.fn()}
index 229c1295c61330907ea5973e620439c3eea7f78b..e5bddc372ab9db9f72900f51f21b3a30af199528 100644 (file)
@@ -77,6 +77,7 @@ exports[`renders 1`] = `
             <PageHeader
               currentUser={
                 Object {
+                  "dismissedNotices": Object {},
                   "isLoggedIn": true,
                 }
               }
@@ -123,6 +124,7 @@ exports[`renders 1`] = `
           cardType="overall"
           currentUser={
             Object {
+              "dismissedNotices": Object {},
               "isLoggedIn": true,
             }
           }
index 57b9a0152f6e9cf633b696dff08af41a2afadf51..0269dea8799063092e2dc831eff9fefcfbbd0c93 100644 (file)
@@ -34,6 +34,9 @@ exports[`restore access shows the restore access modal 1`] = `
 <RestoreAccessModal
   currentUser={
     Object {
+      "dismissedNotices": Object {
+        "educationPrinciples": false,
+      },
       "groups": Array [],
       "isLoggedIn": true,
       "login": "luke",
index fe4b1b55bb05f7d0d97db1310b76331005701d3f..2931486759bcd59729ccc9834f05f1eaf226038a 100644 (file)
@@ -298,6 +298,9 @@ exports[`should render correctly: anonymous user 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "isLoggedIn": false,
         }
       }
@@ -688,6 +691,9 @@ exports[`should render correctly: assignee without name 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "isLoggedIn": false,
         }
       }
@@ -1078,6 +1084,9 @@ exports[`should render correctly: default 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "isLoggedIn": false,
         }
       }
@@ -1468,6 +1477,9 @@ exports[`should render correctly: deleted assignee 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "isLoggedIn": false,
         }
       }
@@ -1871,6 +1883,9 @@ exports[`should render correctly: show success modal 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "isLoggedIn": false,
         }
       }
@@ -2261,6 +2276,9 @@ exports[`should render correctly: unassigned 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "isLoggedIn": false,
         }
       }
index cbd51729559077b48122ca1d01548f9d6dd6e845..6b8328a15a824d1e5f62896fc9ce5f4dc371ed74 100644 (file)
@@ -14,6 +14,9 @@ exports[`should render correctly: editing 1`] = `
         allowCurrentUserSelection={false}
         loggedInUser={
           Object {
+            "dismissedNotices": Object {
+              "educationPrinciples": false,
+            },
             "groups": Array [],
             "isLoggedIn": true,
             "login": "luke",
index faea76c1c68cd262d67234ceeef42dd138ef60f4..ddeed501f58fd6c1783d483a1fdd50273c2c76dd 100644 (file)
@@ -29,6 +29,9 @@ exports[`should render correctly 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
@@ -69,6 +72,9 @@ exports[`should render correctly 2`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
index 3a1b0cf85eb224537e82a0efb59801dcb897fbe8..136a91a139492669f794e1d8c0d9a85503798abe 100644 (file)
@@ -26,14 +26,14 @@ import Suggestions from '../../components/embed-docs-modal/Suggestions';
 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;
 }
index 0855aa622f0e79ce1a0cb0812fc685c40bd1376d..d309c8b36cd124f1d7e22d02ccd597f135525ef1 100644 (file)
@@ -60,7 +60,7 @@ jest.mock('../../../api/users', () => ({
 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(() => {
index 7cded9afb033c9424343fb23adfce45915b03250..cfb5fcdec4aa4ea794988274cb4f132ceda4dd57 100644 (file)
@@ -29,6 +29,7 @@ exports[`should render correctly 1`] = `
   <UsersList
     currentUser={
       Object {
+        "dismissedNotices": Object {},
         "isLoggedIn": true,
         "login": "luke",
       }
@@ -70,6 +71,7 @@ exports[`should render correctly 2`] = `
   <UsersList
     currentUser={
       Object {
+        "dismissedNotices": Object {},
         "isLoggedIn": true,
         "login": "luke",
       }
index caa9afc503f4aac60808815e8ae8578e3a34cbb5..fef597352744a0d9475e21942296196e743aa953 100644 (file)
@@ -55,7 +55,7 @@ function shallowRender(isLoggedIn = true) {
   return shallow(
     <CurrentUserContext.Provider
       value={{
-        currentUser: { isLoggedIn },
+        currentUser: { isLoggedIn, dismissedNotices: {} },
         updateCurrentUserHomepage: () => {},
         updateCurrentUserSonarLintAdSeen: () => {}
       }}>
index d083f47d3a4ca150b92e30663f41c875542cb0cd..0494a5320c4bae91a8279bba9f5506389369de03 100644 (file)
 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';
@@ -29,50 +32,73 @@ import './style.css';
 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>
+              );
+            })}
+          </>
+        )}
+      </>
+    );
+  }
 }
diff --git a/server/sonar-web/src/main/js/components/rules/TabViewer.tsx b/server/sonar-web/src/main/js/components/rules/TabViewer.tsx
new file mode 100644 (file)
index 0000000..2817f30
--- /dev/null
@@ -0,0 +1,239 @@
+/*
+ * 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}
+      />
+    )
+  };
+};
index e0c36bff9854bc791216b6ae1fabdfc6c41b5227..11ed17cf6db9e9c14a1cc06b0e0d0aadadd412d8 100644 (file)
   padding-left: 16px;
   padding-right: 16px;
 }
+
+.tab-view-header {
+  z-index: 100;
+  position: sticky;
+  top: 118px;
+  background-color: white;
+  padding-top: 20px;
+}
index 0770dd01df86d4cd406b25f80701b04361184e7d..6d0a8ba9332b93649c8ba73cd73fce38a0586e7b 100644 (file)
@@ -27,6 +27,9 @@ exports[`should render correctly 1`] = `
   }
   currentUser={
     Object {
+      "dismissedNotices": Object {
+        "educationPrinciples": false,
+      },
       "groups": Array [],
       "isLoggedIn": true,
       "login": "luke",
index 5d03103db9514e02cfc17865e93490e641f5effe..c8591f265ee37866c2d9526a88cfebf5d87a7087 100644 (file)
@@ -382,6 +382,9 @@ exports[`should render correctly: azure pipelines tutorial 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
@@ -427,6 +430,9 @@ exports[`should render correctly: github actions tutorial 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
@@ -474,6 +480,9 @@ exports[`should render correctly: gitlab tutorial 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
@@ -563,6 +572,9 @@ exports[`should render correctly: manual tutorial 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
index 8150d91badf896f6b87ece76b03cc6286a9777df..f1f1573b62e08ec32fb9f5d0cc66bd5af383c4a7 100644 (file)
@@ -135,6 +135,9 @@ exports[`should render correctly 3`] = `
           }
           currentUser={
             Object {
+              "dismissedNotices": Object {
+                "educationPrinciples": false,
+              },
               "groups": Array [],
               "isLoggedIn": true,
               "login": "luke",
index 0384a6cc607a0998ae7c9f9024823c0a58b364c6..566edb7ad77c3ea8dada3e7c4b5dce0c8c5e482a 100644 (file)
@@ -87,6 +87,9 @@ exports[`should render correctly: repo variable step content 1`] = `
   }
   currentUser={
     Object {
+      "dismissedNotices": Object {
+        "educationPrinciples": false,
+      },
       "groups": Array [],
       "isLoggedIn": true,
       "login": "luke",
index c4b429b81435c46c79b72411d4a111ea39973525..56e3ddf6a19f13171eb199c59d8ebd2c49436e3f 100644 (file)
@@ -65,6 +65,9 @@ exports[`should render correctly 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "groups": Array [],
           "isLoggedIn": true,
           "login": "luke",
index 7c34cbefba159a99ef594223f302feca7411bfc8..2e1f5c44802dbf1b632ba51b03bbc47f53d4c892 100644 (file)
@@ -87,6 +87,9 @@ exports[`should render correctly: secrets step content 1`] = `
   }
   currentUser={
     Object {
+      "dismissedNotices": Object {
+        "educationPrinciples": false,
+      },
       "groups": Array [],
       "isLoggedIn": true,
       "login": "luke",
index 63e43b920ea280cfc3875e5dc884a3969d19bb5b..7b16680422f7e4a836b148a608d263d9db9b01c7 100644 (file)
@@ -75,6 +75,9 @@ exports[`should render correctly: default 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "groups": Array [],
           "isLoggedIn": true,
           "login": "luke",
@@ -249,6 +252,9 @@ exports[`should render correctly: with binding information 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "groups": Array [],
           "isLoggedIn": true,
           "login": "luke",
index 3def93d2518667e155cf316ced975967bd34dfe2..656c23dc09f6ce98d21319cd9f1dad9c22c3eb0d 100644 (file)
@@ -82,6 +82,9 @@ exports[`should render correctly: initial content 1`] = `
       }
       currentUser={
         Object {
+          "dismissedNotices": Object {
+            "educationPrinciples": false,
+          },
           "groups": Array [],
           "isLoggedIn": true,
           "login": "luke",
index e3e33091f2bf5e0df4fff9f814c8fff22258a9e4..d9fa3955af6b7bc8e63117d92bded68b9145c78e 100644 (file)
@@ -66,6 +66,9 @@ exports[`should render correctly 1`] = `
     }
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
index 4a4af90decaa45e7f704435de8e71499f880cad1..108c0ead8cd1a115865146af40b09f4d9ffbaf1f 100644 (file)
@@ -21,6 +21,9 @@ exports[`renders correctly: default 1`] = `
   <TokenStep
     currentUser={
       Object {
+        "dismissedNotices": Object {
+          "educationPrinciples": false,
+        },
         "groups": Array [],
         "isLoggedIn": true,
         "login": "luke",
index 7a01a7dc5ccf2a131b24c8ede7f5de2fc7cf168d..9ca206f26fd6eaa5de66b237114ddea92d97c004 100644 (file)
@@ -300,6 +300,9 @@ export function mockCondition(overrides: Partial<Condition> = {}): Condition {
 export function mockCurrentUser(overrides: Partial<CurrentUser> = {}): CurrentUser {
   return {
     isLoggedIn: false,
+    dismissedNotices: {
+      educationPrinciples: false
+    },
     ...overrides
   };
 }
@@ -311,6 +314,9 @@ export function mockLoggedInUser(overrides: Partial<LoggedInUser> = {}): LoggedI
     login: 'luke',
     name: 'Skywalker',
     scmAccounts: [],
+    dismissedNotices: {
+      educationPrinciples: false
+    },
     ...overrides
   };
 }
index 5c01dc64b52bc807fb0945550e35da734ef69206..005378011596fad26f23b73967d901ebb082cb0d 100644 (file)
@@ -22,6 +22,17 @@ export interface CurrentUser {
   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 {
index c2d3c449d9b3c52ca38cd34fcc0aeeb71b373a1b..74fe9bc4ceaa5c1e8a8a587fd5a65eb7c3015c30 100644 (file)
@@ -859,7 +859,7 @@ issue.transition.resetastoreview=Reset as To Review
 issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again
 issue.tabs.code=Where is the issue?
 issue.tabs.why=Why is this an issue?
-issue.tabs.how=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
@@ -1921,6 +1921,8 @@ coding_rules.description_context.other=Other
 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