From f2034fc34a3f742fb0294e84e0c5983bdd7a91d2 Mon Sep 17 00:00:00 2001 From: Revanshu Paliwal Date: Thu, 7 Jul 2022 13:48:39 +0200 Subject: [PATCH] SONAR-16599 Displaying notification in rules more info tab --- .../src/main/js/api/mocks/CodingRulesMock.ts | 59 ++++- .../main/js/api/mocks/IssuesServiceMock.ts | 23 +- server/sonar-web/src/main/js/api/users.ts | 6 +- .../__tests__/StartupModal-test.tsx | 3 +- .../__snapshots__/ResetPassword-test.tsx.snap | 3 + .../CurrentUserContextProvider.tsx | 2 +- .../__snapshots__/Extension-test.tsx.snap | 6 + .../component/__tests__/HeaderMeta-test.tsx | 2 +- .../nav/global/__tests__/GlobalNav-test.tsx | 8 +- .../global/__tests__/GlobalNavMenu-test.tsx | 6 +- .../__snapshots__/GlobalNav-test.tsx.snap | 2 + .../coding-rules/__tests__/CodingRules-it.ts | 105 +++++++- .../coding-rules/components/RuleTabViewer.tsx | 129 +++------- .../src/main/js/apps/coding-rules/styles.css | 9 + .../js/apps/issues/__tests__/IssueApp-it.tsx | 5 +- .../apps/issues/components/IssueTabViewer.tsx | 137 +++------- .../__tests__/BulkChangeModal-test.tsx | 2 +- .../components/__tests__/IssuesApp-test.tsx | 12 + .../src/main/js/apps/issues/styles.css | 1 + .../__snapshots__/EmptyOverview-test.tsx.snap | 6 + .../components/__tests__/AllProjects-test.tsx | 2 +- .../__tests__/EmptyInstance-test.tsx | 13 +- .../components/__tests__/PageHeader-test.tsx | 2 +- .../__snapshots__/AllProjects-test.tsx.snap | 2 + .../ProjectRowActions-test.tsx.snap | 3 + .../HotspotViewerRenderer-test.tsx.snap | 18 ++ .../AssigneeRenderer-test.tsx.snap | 3 + .../__snapshots__/TutorialsApp-test.tsx.snap | 6 + .../src/main/js/apps/users/UsersApp.tsx | 4 +- .../js/apps/users/__tests__/UsersApp-test.tsx | 2 +- .../__snapshots__/UsersApp-test.tsx.snap | 2 + .../hoc/__tests__/whenLoggedIn-test.tsx | 2 +- .../rules/MoreInfoRuleDescription.tsx | 100 +++++--- .../main/js/components/rules/TabViewer.tsx | 239 ++++++++++++++++++ .../src/main/js/components/rules/style.css | 8 + .../TutorialSelection-test.tsx.snap | 3 + .../TutorialSelectionRenderer-test.tsx.snap | 12 + .../AzurePipelinesTutorial-test.tsx.snap | 3 + .../BitbucketPipelinesTutorial-test.tsx.snap | 3 + .../RepositoryVariables-test.tsx.snap | 3 + .../GitHubActionTutorial-test.tsx.snap | 3 + .../__snapshots__/SecretStep-test.tsx.snap | 6 + .../EnvironmentVariablesStep-test.tsx.snap | 3 + .../GitLabCITutorial-test.tsx.snap | 3 + .../ManualTutorial-test.tsx.snap | 3 + .../src/main/js/helpers/testMocks.ts | 6 + server/sonar-web/src/main/js/types/users.ts | 11 + .../resources/org/sonar/l10n/core.properties | 4 +- 48 files changed, 743 insertions(+), 252 deletions(-) create mode 100644 server/sonar-web/src/main/js/components/rules/TabViewer.tsx diff --git a/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts b/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts index b5ad81cb256..f23c2913c7c 100644 --- a/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts @@ -19,11 +19,17 @@ */ 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 Awsome Reading'; + 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(response: T): Promise { return Promise.resolve(cloneDeep(response)); } diff --git a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts index 0e1fe610143..7b83314b8d3 100644 --- a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts @@ -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) { 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 { @@ -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(response: T): Promise { return Promise.resolve(cloneDeep(response)); } diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 9b94961ebc8..e512718770e 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -20,12 +20,16 @@ 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 { 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; diff --git a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx index 96a80a3e0af..4d47866e21e 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx @@ -55,7 +55,8 @@ const LOGGED_IN_USER: LoggedInUser = { isLoggedIn: true, login: 'luke', name: 'Skywalker', - scmAccounts: [] + scmAccounts: [], + dismissedNotices: {} }; beforeEach(() => { diff --git a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/ResetPassword-test.tsx.snap b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/ResetPassword-test.tsx.snap index e6107f27865..b8f17c6c10a 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/ResetPassword-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/ResetPassword-test.tsx.snap @@ -29,6 +29,9 @@ exports[`should render correctly 1`] = ` onPasswordChange={[Function]} user={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContextProvider.tsx b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContextProvider.tsx index 9ffe3d1ec59..fb70ec97b91 100644 --- a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContextProvider.tsx +++ b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContextProvider.tsx @@ -32,7 +32,7 @@ interface State { export default class CurrentUserContextProvider extends React.PureComponent { constructor(props: Props) { super(props); - this.state = { currentUser: props.currentUser ?? { isLoggedIn: false } }; + this.state = { currentUser: props.currentUser ?? { isLoggedIn: false, dismissedNotices: {} } }; } updateCurrentUserHomepage = (homepage: HomePage) => { diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap index 44929f62890..83c71e6d312 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap @@ -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, } } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx index 36cd81491a2..bc5ff09ba86 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx @@ -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); }); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx index d85531ebc4d..d0a5d89f224 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx @@ -35,5 +35,11 @@ it('should render correctly', async () => { }); function shallowRender(props: Partial = {}) { - return shallow(); + return shallow( + + ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.tsx index 5afe85b9f35..0beed7916cf 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.tsx @@ -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 }); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap index c693f643ee9..a6e53684300 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap @@ -10,6 +10,7 @@ exports[`should render correctly: anonymous users 1`] = ` { 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, 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 cebd8c1d4d8..4ad62d13211 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 @@ -19,9 +19,14 @@ */ 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 { - 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 { + computeTabs = (showNotice: boolean, educationPrinciplesRef: React.RefObject) => { 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] && ( - - ) - }, + 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] && ( ) }, - { - key: RuleTabKeys.HowToFixIt, - label: translate('coding_rules.description_section.title', RuleTabKeys.HowToFixIt), - content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( - - ) - }, - { - key: RuleTabKeys.MoreInfo, - label: translate('coding_rules.description_section.title', RuleTabKeys.MoreInfo), - content: (ruleDetails.educationPrinciples || - 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; - - 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 { dangerouslySetInnerHTML={{ __html: sanitizeString(intro) }} /> )} - - -
- {currentTab.content} -
+ ); } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/styles.css b/server/sonar-web/src/main/js/apps/coding-rules/styles.css index 0c70cdd2fe6..dcc291a7006 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/styles.css +++ b/server/sonar-web/src/main/js/apps/coding-rules/styles.css @@ -282,3 +282,12 @@ .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); +} 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 ab7c94ca37b..aa9052cba31 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 @@ -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(); 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 005b6fdbe82..52b264780b6 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 @@ -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 { - 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 { + computeTabs = (showNotice: boolean, educationPrinciplesRef: React.RefObject) => { const { ruleDetails, codeTabContent, @@ -91,6 +51,9 @@ export default class IssueViewerTabs extends React.PureComponent { 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 { return [ { - key: IssueTabKeys.Code, - label: translate('issue.tabs', IssueTabKeys.Code), + key: TabKeys.Code, + label: translate('issue.tabs', TabKeys.Code), content:
{codeTabContent}
}, - { - key: IssueTabKeys.WhyIsThisAnIssue, - label: translate('issue.tabs', IssueTabKeys.WhyIsThisAnIssue), - content: rootCauseDescriptionSections && ( - - ) - }, - { - key: IssueTabKeys.HowToFixIt, - label: translate('issue.tabs', IssueTabKeys.HowToFixIt), - content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( - - ) - }, - { - key: IssueTabKeys.MoreInfo, - label: translate('issue.tabs', IssueTabKeys.MoreInfo), - content: (ruleDetails.educationPrinciples || - 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; - } + }; 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 ( <>
{ {key}
- - {selectedTab && ( -
- {selectedTab.content} -
- )} + ); } diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx index 19c592dbb47..6e7899de8a8 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx @@ -111,7 +111,7 @@ const getWrapper = (issues: Issue[]) => { return shallow( Promise.resolve({ issues, 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 3b035bb3258..62a1983a216 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,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); diff --git a/server/sonar-web/src/main/js/apps/issues/styles.css b/server/sonar-web/src/main/js/apps/issues/styles.css index 99fcc9ad473..00567cb0303 100644 --- a/server/sonar-web/src/main/js/apps/issues/styles.css +++ b/server/sonar-web/src/main/js/apps/issues/styles.css @@ -245,6 +245,7 @@ top: 48px; background-color: white; padding-top: 20px; + height: 50px; } .issue-project-level.issue-header { diff --git a/server/sonar-web/src/main/js/apps/overview/components/__tests__/__snapshots__/EmptyOverview-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/components/__tests__/__snapshots__/EmptyOverview-test.tsx.snap index 0feced6e7c7..afd2b0d3b0c 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/__tests__/__snapshots__/EmptyOverview-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/overview/components/__tests__/__snapshots__/EmptyOverview-test.tsx.snap @@ -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", diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx index 77d28e87274..f574600c073 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx @@ -152,7 +152,7 @@ function shallowRender( ) { const wrapper = shallow( { expect( - shallow() + shallow( + + ) ).toMatchSnapshot(); expect( shallow( ) diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/PageHeader-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/__tests__/PageHeader-test.tsx index c50803a8740..afff1343f10 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/PageHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/PageHeader-test.tsx @@ -57,7 +57,7 @@ it('should render switch the default sorting option for anonymous users', () => function shallowRender(props?: {}) { return shallow( ({ const getIdentityProviders = require('../../../api/users').getIdentityProviders as jest.Mock; const searchUsers = require('../../../api/users').searchUsers as jest.Mock; -const currentUser = { isLoggedIn: true, login: 'luke' }; +const currentUser = { isLoggedIn: true, login: 'luke', dismissedNotices: {} }; const location = { pathname: '', query: {} } as Location; beforeEach(() => { diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap index 7cded9afb03..cfb5fcdec4a 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap @@ -29,6 +29,7 @@ exports[`should render correctly 1`] = ` {}, updateCurrentUserSonarLintAdSeen: () => {} }}> 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 d083f47d3a4..0494a5320c4 100644 --- a/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx +++ b/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx @@ -20,7 +20,10 @@ 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; } const EDUCATION_PRINCIPLES_MAP: Dict = { defense_in_depth: DefenseInDepth, least_trust_principle: LeastTrustPrinciple }; +export default class MoreInfoRuleDescription extends React.PureComponent { + 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 && ( - <> -
-

- {translate('coding_rules.more_info.resources.title')} -

+ render() { + const { showNotification, sections = [], educationPrinciples = [] } = this.props; + return ( + <> + {showNotification && ( +
+ +

+ {translate('coding_rules.more_info.notification_message')} +

+ { + this.handleNotificationScroll(); + }}> + {translate('coding_rules.more_info.scroll_message')} + +
- - - )} + )} + {sections.length > 0 && ( + <> +
+

+ {translate('coding_rules.more_info.resources.title')} +

+
+ + + )} - {educationPrinciples.length > 0 && ( - <> -
-

- {translate('coding_rules.more_info.education_principles.title')} -

-
- {educationPrinciples.map(key => { - const Concept = EDUCATION_PRINCIPLES_MAP[key]; - if (Concept === undefined) { - return null; - } - return ( -
- -
- ); - })} - - )} - - ); + {educationPrinciples.length > 0 && ( + <> +
+

+ {translate('coding_rules.more_info.education_principles.title')} +

+
+ {educationPrinciples.map(key => { + const Concept = EDUCATION_PRINCIPLES_MAP[key]; + if (Concept === undefined) { + return null; + } + return ( +
+ +
+ ); + })} + + )} + + ); + } } 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 index 00000000000..2817f308ec1 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/TabViewer.tsx @@ -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 + ) => 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 { + showNotification = false; + educationPrinciplesRef: React.RefObject; + + 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 ( + <> +
+ +
+
+ {currentTab.content} +
+ + ); + } +} + +export const getMoreInfoTab = ( + showNotification: boolean, + descriptionSectionsByKey: Dictionary, + educationPrinciplesRef: React.RefObject, + title: string, + educationPrinciples?: string[] +) => { + return { + key: TabKeys.MoreInfo, + label: showNotification ? ( +
+ {title} +
+
+ ) : ( + title + ), + content: ((educationPrinciples && educationPrinciples.length > 0) || + descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( + + ) + }; +}; + +export const getHowToFixTab = ( + descriptionSectionsByKey: Dictionary, + title: string, + ruleDescriptionContextKey?: string +) => { + return { + key: TabKeys.HowToFixIt, + label: title, + content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( + + ) + }; +}; + +export const getWhyIsThisAnIssueTab = ( + rootCauseDescriptionSections: RuleDescriptionSection[], + descriptionSectionsByKey: Dictionary, + title: string, + ruleDescriptionContextKey?: string +) => { + return { + key: TabKeys.WhyIsThisAnIssue, + label: title, + content: rootCauseDescriptionSections && ( + + ) + }; +}; diff --git a/server/sonar-web/src/main/js/components/rules/style.css b/server/sonar-web/src/main/js/components/rules/style.css index e0c36bff985..11ed17cf6db 100644 --- a/server/sonar-web/src/main/js/components/rules/style.css +++ b/server/sonar-web/src/main/js/components/rules/style.css @@ -28,3 +28,11 @@ padding-left: 16px; padding-right: 16px; } + +.tab-view-header { + z-index: 100; + position: sticky; + top: 118px; + background-color: white; + padding-top: 20px; +} diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap index 0770dd01df8..6d0a8ba9332 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap @@ -27,6 +27,9 @@ exports[`should render correctly 1`] = ` } currentUser={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap index 5d03103db95..c8591f265ee 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap @@ -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", diff --git a/server/sonar-web/src/main/js/components/tutorials/azure-pipelines/__tests__/__snapshots__/AzurePipelinesTutorial-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/azure-pipelines/__tests__/__snapshots__/AzurePipelinesTutorial-test.tsx.snap index 8150d91badf..f1f1573b62e 100644 --- a/server/sonar-web/src/main/js/components/tutorials/azure-pipelines/__tests__/__snapshots__/AzurePipelinesTutorial-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/azure-pipelines/__tests__/__snapshots__/AzurePipelinesTutorial-test.tsx.snap @@ -135,6 +135,9 @@ exports[`should render correctly 3`] = ` } currentUser={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/BitbucketPipelinesTutorial-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/BitbucketPipelinesTutorial-test.tsx.snap index 0384a6cc607..566edb7ad77 100644 --- a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/BitbucketPipelinesTutorial-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/BitbucketPipelinesTutorial-test.tsx.snap @@ -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", diff --git a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/RepositoryVariables-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/RepositoryVariables-test.tsx.snap index c4b429b8143..56e3ddf6a19 100644 --- a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/RepositoryVariables-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/__snapshots__/RepositoryVariables-test.tsx.snap @@ -65,6 +65,9 @@ exports[`should render correctly 1`] = ` } currentUser={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/GitHubActionTutorial-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/GitHubActionTutorial-test.tsx.snap index 7c34cbefba1..2e1f5c44802 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/GitHubActionTutorial-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/GitHubActionTutorial-test.tsx.snap @@ -87,6 +87,9 @@ exports[`should render correctly: secrets step content 1`] = ` } currentUser={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/SecretStep-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/SecretStep-test.tsx.snap index 63e43b920ea..7b16680422f 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/SecretStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/__snapshots__/SecretStep-test.tsx.snap @@ -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", diff --git a/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/EnvironmentVariablesStep-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/EnvironmentVariablesStep-test.tsx.snap index 3def93d2518..656c23dc09f 100644 --- a/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/EnvironmentVariablesStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/EnvironmentVariablesStep-test.tsx.snap @@ -82,6 +82,9 @@ exports[`should render correctly: initial content 1`] = ` } currentUser={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/GitLabCITutorial-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/GitLabCITutorial-test.tsx.snap index e3e33091f2b..d9fa3955af6 100644 --- a/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/GitLabCITutorial-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/gitlabci/__tests__/__snapshots__/GitLabCITutorial-test.tsx.snap @@ -66,6 +66,9 @@ exports[`should render correctly 1`] = ` } currentUser={ Object { + "dismissedNotices": Object { + "educationPrinciples": false, + }, "groups": Array [], "isLoggedIn": true, "login": "luke", diff --git a/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap index 4a4af90deca..108c0ead8cd 100644 --- a/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap @@ -21,6 +21,9 @@ exports[`renders correctly: default 1`] = ` = {}): Condition { export function mockCurrentUser(overrides: Partial = {}): CurrentUser { return { isLoggedIn: false, + dismissedNotices: { + educationPrinciples: false + }, ...overrides }; } @@ -311,6 +314,9 @@ export function mockLoggedInUser(overrides: Partial = {}): LoggedI login: 'luke', name: 'Skywalker', scmAccounts: [], + dismissedNotices: { + educationPrinciples: false + }, ...overrides }; } diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index 5c01dc64b52..00537801159 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -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 { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index c2d3c449d9b..74fe9bc4cea 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 -- 2.39.5