From 74ec7514d3d1320a259adbda7bc78d3d932d0786 Mon Sep 17 00:00:00 2001 From: Ambroise C Date: Fri, 18 Aug 2023 16:14:28 +0200 Subject: [PATCH] SONAR-20176 Add banner to notify about automatic branches NCD update --- .../projectNewCode/components/BranchList.tsx | 39 ++++- .../ProjectNewCodeDefinitionApp-it.tsx | 151 +++++++++++++++--- .../BranchNCDAutoUpdateMessage.tsx | 102 ++++++++++++ .../NCDAutoUpdateMessage.tsx | 5 +- .../components/new-code-definition/utils.ts | 19 ++- .../resources/org/sonar/l10n/core.properties | 2 + 6 files changed, 281 insertions(+), 37 deletions(-) create mode 100644 server/sonar-web/src/main/js/components/new-code-definition/BranchNCDAutoUpdateMessage.tsx diff --git a/server/sonar-web/src/main/js/apps/projectNewCode/components/BranchList.tsx b/server/sonar-web/src/main/js/apps/projectNewCode/components/BranchList.tsx index 8b4f0fac7d6..3d75feda42e 100644 --- a/server/sonar-web/src/main/js/apps/projectNewCode/components/BranchList.tsx +++ b/server/sonar-web/src/main/js/apps/projectNewCode/components/BranchList.tsx @@ -22,6 +22,11 @@ import { listBranchesNewCodeDefinition, resetNewCodeDefinition, } from '../../../api/newCodeDefinition'; +import BranchNCDAutoUpdateMessage from '../../../components/new-code-definition/BranchNCDAutoUpdateMessage'; +import { + PreviouslyNonCompliantBranchNCD, + isPreviouslyNonCompliantDaysNCD, +} from '../../../components/new-code-definition/utils'; import Spinner from '../../../components/ui/Spinner'; import { isBranch, sortBranches } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; @@ -43,6 +48,7 @@ interface State { branches: BranchWithNewCodePeriod[]; editedBranch?: BranchWithNewCodePeriod; loading: boolean; + previouslyNonCompliantBranchNCDs?: PreviouslyNonCompliantBranchNCD[]; } export default class BranchList extends React.PureComponent { @@ -93,7 +99,15 @@ export default class BranchList extends React.PureComponent { }; }); - this.setState({ branches: branchesWithBaseline, loading: false }); + const previouslyNonCompliantBranchNCDs = newCodePeriods.filter( + isPreviouslyNonCompliantDaysNCD + ); + + this.setState({ + branches: branchesWithBaseline, + loading: false, + previouslyNonCompliantBranchNCDs, + }); }, () => { this.setState({ loading: false }); @@ -116,11 +130,14 @@ export default class BranchList extends React.PureComponent { }; closeEditModal = (branch?: string, newSetting?: NewCodeDefinition) => { - if (branch) { - this.setState({ + if (branch !== undefined) { + this.setState(({ previouslyNonCompliantBranchNCDs }) => ({ branches: this.updateBranchNewCodePeriod(branch, newSetting), + previouslyNonCompliantBranchNCDs: previouslyNonCompliantBranchNCDs?.filter( + ({ branchKey }) => branchKey !== branch + ), editedBranch: undefined, - }); + })); } else { this.setState({ editedBranch: undefined }); } @@ -136,8 +153,8 @@ export default class BranchList extends React.PureComponent { }; render() { - const { branchList, inheritedSetting, globalNewCodeDefinition } = this.props; - const { branches, editedBranch, loading } = this.state; + const { branchList, component, inheritedSetting, globalNewCodeDefinition } = this.props; + const { branches, editedBranch, loading, previouslyNonCompliantBranchNCDs } = this.state; if (branches.length < 1) { return null; @@ -148,7 +165,13 @@ export default class BranchList extends React.PureComponent { } return ( - <> +
+ {previouslyNonCompliantBranchNCDs && ( + + )} @@ -182,7 +205,7 @@ export default class BranchList extends React.PureComponent { globalNewCodeDefinition={globalNewCodeDefinition} /> )} - + ); } } diff --git a/server/sonar-web/src/main/js/apps/projectNewCode/components/__tests__/ProjectNewCodeDefinitionApp-it.tsx b/server/sonar-web/src/main/js/apps/projectNewCode/components/__tests__/ProjectNewCodeDefinitionApp-it.tsx index 3a3a60974b7..084e134f393 100644 --- a/server/sonar-web/src/main/js/apps/projectNewCode/components/__tests__/ProjectNewCodeDefinitionApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectNewCode/components/__tests__/ProjectNewCodeDefinitionApp-it.tsx @@ -17,11 +17,13 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { within } from '@testing-library/react'; +import { act, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { first, last } from 'lodash'; import selectEvent from 'react-select-event'; +import { MessageTypes } from '../../../../api/messages'; import BranchesServiceMock from '../../../../api/mocks/BranchesServiceMock'; +import MessagesServiceMock from '../../../../api/mocks/MessagesServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { ProjectActivityServiceMock } from '../../../../api/mocks/ProjectActivityServiceMock'; import { mockComponent } from '../../../../helpers/mocks/component'; @@ -31,7 +33,7 @@ import { RenderContext, renderAppWithComponentContext, } from '../../../../helpers/testReactTestingUtils'; -import { byRole, byText } from '../../../../helpers/testSelector'; +import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; import { Feature } from '../../../../types/features'; import { NewCodeDefinitionType } from '../../../../types/new-code-definition'; import routes from '../../routes'; @@ -40,19 +42,21 @@ jest.mock('../../../../api/newCodeDefinition'); jest.mock('../../../../api/projectActivity'); jest.mock('../../../../api/branches'); -const codePeriodsMock = new NewCodeDefinitionServiceMock(); +const newCodeDefinitionMock = new NewCodeDefinitionServiceMock(); const projectActivityMock = new ProjectActivityServiceMock(); const branchHandler = new BranchesServiceMock(); +const messagesMock = new MessagesServiceMock(); afterEach(() => { branchHandler.reset(); - codePeriodsMock.reset(); + newCodeDefinitionMock.reset(); projectActivityMock.reset(); + messagesMock.reset(); }); it('renders correctly without branch support feature', async () => { const { ui } = getPageObjects(); - renderProjectBaselineApp(); + renderProjectNewCodeDefinitionApp(); await ui.appIsLoaded(); expect(await ui.generalSettingRadio.find()).toBeChecked(); @@ -67,14 +71,14 @@ it('renders correctly without branch support feature', async () => { }); it('prevents selection of global setting if it is not compliant and warns non-admin about it', async () => { - codePeriodsMock.setNewCodePeriod({ + newCodeDefinitionMock.setNewCodePeriod({ type: NewCodeDefinitionType.NumberOfDays, value: '99', inherited: true, }); const { ui } = getPageObjects(); - renderProjectBaselineApp(); + renderProjectNewCodeDefinitionApp(); await ui.appIsLoaded(); expect(await ui.generalSettingRadio.find()).toBeChecked(); @@ -83,14 +87,14 @@ it('prevents selection of global setting if it is not compliant and warns non-ad }); it('prevents selection of global setting if it is not compliant and warns admin about it', async () => { - codePeriodsMock.setNewCodePeriod({ + newCodeDefinitionMock.setNewCodePeriod({ type: NewCodeDefinitionType.NumberOfDays, value: '99', inherited: true, }); const { ui } = getPageObjects(); - renderProjectBaselineApp({ appState: mockAppState({ canAdmin: true }) }); + renderProjectNewCodeDefinitionApp({ appState: mockAppState({ canAdmin: true }) }); await ui.appIsLoaded(); expect(await ui.generalSettingRadio.find()).toBeChecked(); @@ -101,7 +105,7 @@ it('prevents selection of global setting if it is not compliant and warns admin it('renders correctly with branch support feature', async () => { const { ui } = getPageObjects(); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], appState: mockAppState({ canAdmin: true }), }); @@ -120,7 +124,7 @@ it('renders correctly with branch support feature', async () => { it('can set previous version specific setting', async () => { const { ui, user } = getPageObjects(); - renderProjectBaselineApp(); + renderProjectNewCodeDefinitionApp(); await ui.appIsLoaded(); expect(await ui.previousVersionRadio.find()).toHaveClass('disabled'); @@ -141,7 +145,7 @@ it('can set previous version specific setting', async () => { it('can set number of days specific setting', async () => { const { ui, user } = getPageObjects(); - renderProjectBaselineApp(); + renderProjectNewCodeDefinitionApp(); await ui.appIsLoaded(); expect(await ui.numberDaysRadio.find()).toHaveClass('disabled'); @@ -162,7 +166,7 @@ it('can set number of days specific setting', async () => { it('can set reference branch specific setting', async () => { const { ui, user } = getPageObjects(); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], }); await ui.appIsLoaded(); @@ -179,11 +183,11 @@ it('can set reference branch specific setting', async () => { it('cannot set specific analysis setting', async () => { const { ui } = getPageObjects(); - codePeriodsMock.setNewCodePeriod({ + newCodeDefinitionMock.setNewCodePeriod({ type: NewCodeDefinitionType.SpecificAnalysis, value: 'analysis_id', }); - renderProjectBaselineApp(); + renderProjectNewCodeDefinitionApp(); await ui.appIsLoaded(); expect(await ui.specificAnalysisRadio.find()).toBeChecked(); @@ -198,7 +202,7 @@ it('cannot set specific analysis setting', async () => { it('renders correctly branch modal', async () => { const { ui } = getPageObjects(); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], }); await ui.appIsLoaded(); @@ -210,7 +214,7 @@ it('renders correctly branch modal', async () => { it('can set a previous version setting for branch', async () => { const { ui, user } = getPageObjects(); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], }); await ui.appIsLoaded(); @@ -232,7 +236,7 @@ it('can set a previous version setting for branch', async () => { it('can set a number of days setting for branch', async () => { const { ui } = getPageObjects(); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], }); await ui.appIsLoaded(); @@ -246,14 +250,14 @@ it('can set a number of days setting for branch', async () => { it('cannot set a specific analysis setting for branch', async () => { const { ui } = getPageObjects(); - codePeriodsMock.setListBranchesNewCode([ + newCodeDefinitionMock.setListBranchesNewCode([ mockNewCodePeriodBranch({ branchKey: 'main', type: NewCodeDefinitionType.SpecificAnalysis, value: 'analysis_id', }), ]); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], }); await ui.appIsLoaded(); @@ -272,7 +276,7 @@ it('cannot set a specific analysis setting for branch', async () => { it('can set a reference branch setting for branch', async () => { const { ui } = getPageObjects(); - renderProjectBaselineApp({ + renderProjectNewCodeDefinitionApp({ featureList: [Feature.BranchSupport], }); await ui.appIsLoaded(); @@ -284,7 +288,108 @@ it('can set a reference branch setting for branch', async () => { ).toBeInTheDocument(); }); -function renderProjectBaselineApp(context: RenderContext = {}, params?: string) { +it('should display NCD banner if some branches had their NCD automatically changed', async () => { + const { ui } = getPageObjects(); + + newCodeDefinitionMock.setListBranchesNewCode([ + { + projectKey: 'test-project:test', + branchKey: 'test-branch', + type: NewCodeDefinitionType.NumberOfDays, + value: '25', + inherited: true, + updatedAt: 1692720953662, + }, + { + projectKey: 'test-project:test', + branchKey: 'master', + type: NewCodeDefinitionType.NumberOfDays, + value: '32', + previousNonCompliantValue: '150', + updatedAt: 1692721852743, + }, + ]); + + renderProjectNewCodeDefinitionApp({ + featureList: [Feature.BranchSupport], + }); + + expect(await ui.branchNCDsBanner.find()).toBeInTheDocument(); + expect( + ui.branchNCDsBanner.byText('new_code_definition.auto_update.branch.list_itemmaster32150').get() + ).toBeInTheDocument(); +}); + +it('should not display NCD banner if some branches had their NCD automatically changed and banne has been dismissed', async () => { + const { ui } = getPageObjects(); + + newCodeDefinitionMock.setListBranchesNewCode([ + { + projectKey: 'test-project:test', + branchKey: 'test-branch', + type: NewCodeDefinitionType.NumberOfDays, + value: '25', + inherited: true, + updatedAt: 1692720953662, + }, + { + projectKey: 'test-project:test', + branchKey: 'master', + type: NewCodeDefinitionType.NumberOfDays, + value: '32', + previousNonCompliantValue: '150', + updatedAt: 1692721852743, + }, + ]); + messagesMock.setMessageDismissed({ + projectKey: 'test-project:test', + messageType: MessageTypes.BranchNcd90, + }); + + renderProjectNewCodeDefinitionApp({ + featureList: [Feature.BranchSupport], + }); + + expect(await ui.branchNCDsBanner.query()).not.toBeInTheDocument(); +}); + +it('should correctly dismiss branch banner', async () => { + const { ui } = getPageObjects(); + + newCodeDefinitionMock.setListBranchesNewCode([ + { + projectKey: 'test-project:test', + branchKey: 'test-branch', + type: NewCodeDefinitionType.NumberOfDays, + value: '25', + inherited: true, + updatedAt: 1692720953662, + }, + { + projectKey: 'test-project:test', + branchKey: 'master', + type: NewCodeDefinitionType.NumberOfDays, + value: '32', + previousNonCompliantValue: '150', + updatedAt: 1692721852743, + }, + ]); + + renderProjectNewCodeDefinitionApp({ + featureList: [Feature.BranchSupport], + }); + + expect(await ui.branchNCDsBanner.find()).toBeInTheDocument(); + + const user = userEvent.setup(); + await act(async () => { + await user.click(ui.dismissButton.get()); + }); + + expect(ui.branchNCDsBanner.query()).not.toBeInTheDocument(); +}); + +function renderProjectNewCodeDefinitionApp(context: RenderContext = {}, params?: string) { return renderAppWithComponentContext( 'baseline', routes, @@ -328,6 +433,8 @@ function getPageObjects() { saved: byText('settings.state.saved'), complianceWarningAdmin: byText('new_code_definition.compliance.warning.explanation.admin'), complianceWarning: byText('new_code_definition.compliance.warning.explanation'), + branchNCDsBanner: byText(/new_code_definition.auto_update.branch.message/), + dismissButton: byLabelText('alert.dismiss'), }; async function appIsLoaded() { diff --git a/server/sonar-web/src/main/js/components/new-code-definition/BranchNCDAutoUpdateMessage.tsx b/server/sonar-web/src/main/js/components/new-code-definition/BranchNCDAutoUpdateMessage.tsx new file mode 100644 index 00000000000..197ef8d285b --- /dev/null +++ b/server/sonar-web/src/main/js/components/new-code-definition/BranchNCDAutoUpdateMessage.tsx @@ -0,0 +1,102 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React, { useCallback, useEffect, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { MessageTypes, checkMessageDismissed, setMessageDismissed } from '../../api/messages'; +import { Component } from '../../types/types'; +import DocLink from '../common/DocLink'; +import DismissableAlertComponent from '../ui/DismissableAlertComponent'; +import { PreviouslyNonCompliantBranchNCD } from './utils'; + +interface NCDAutoUpdateMessageProps { + component: Component; + previouslyNonCompliantBranchNCDs: PreviouslyNonCompliantBranchNCD[]; +} + +export default function NCDAutoUpdateMessage(props: NCDAutoUpdateMessageProps) { + const { component, previouslyNonCompliantBranchNCDs } = props; + const intl = useIntl(); + + const [dismissed, setDismissed] = useState(true); + + const handleBannerDismiss = useCallback(async () => { + await setMessageDismissed({ messageType: MessageTypes.BranchNcd90, projectKey: component.key }); + setDismissed(true); + }, [component]); + + useEffect(() => { + async function checkBranchMessageDismissed() { + if (previouslyNonCompliantBranchNCDs.length > 0) { + const messageStatus = await checkMessageDismissed({ + messageType: MessageTypes.BranchNcd90, + projectKey: component.key, + }); + setDismissed(messageStatus.dismissed); + } + } + + if (previouslyNonCompliantBranchNCDs.length > 0) { + checkBranchMessageDismissed(); + } + }, [component, previouslyNonCompliantBranchNCDs]); + + if (dismissed || previouslyNonCompliantBranchNCDs.length === 0) { + return null; + } + + const branchesList = ( +
    + {previouslyNonCompliantBranchNCDs.map((branchNCD) => ( +
  • + +
  • + ))} +
+ ); + + return ( + + + {intl.formatMessage({ id: 'learn_more' })} + + ), + }} + /> + + ); +} diff --git a/server/sonar-web/src/main/js/components/new-code-definition/NCDAutoUpdateMessage.tsx b/server/sonar-web/src/main/js/components/new-code-definition/NCDAutoUpdateMessage.tsx index cb6d5c95c3f..ecb71e82893 100644 --- a/server/sonar-web/src/main/js/components/new-code-definition/NCDAutoUpdateMessage.tsx +++ b/server/sonar-web/src/main/js/components/new-code-definition/NCDAutoUpdateMessage.tsx @@ -86,10 +86,7 @@ function NCDAutoUpdateMessage(props: NCDAutoUpdateMessageProps) { } ); - if ( - isPreviouslyNonCompliantDaysNCD(newCodeDefinition) && - (!component || !newCodeDefinition?.inherited) - ) { + if (isPreviouslyNonCompliantDaysNCD(newCodeDefinition)) { setPreviouslyNonCompliantNewCodeDefinition(newCodeDefinition); const messageStatus = await checkMessageDismissed( diff --git a/server/sonar-web/src/main/js/components/new-code-definition/utils.ts b/server/sonar-web/src/main/js/components/new-code-definition/utils.ts index 95cf53fc6f4..3dc273cb7c4 100644 --- a/server/sonar-web/src/main/js/components/new-code-definition/utils.ts +++ b/server/sonar-web/src/main/js/components/new-code-definition/utils.ts @@ -19,7 +19,11 @@ */ import { hasGlobalPermission } from '../../helpers/users'; -import { NewCodeDefinition, NewCodeDefinitionType } from '../../types/new-code-definition'; +import { + NewCodeDefinition, + NewCodeDefinitionBranch, + NewCodeDefinitionType, +} from '../../types/new-code-definition'; import { Permissions } from '../../types/permissions'; import { Component } from '../../types/types'; import { CurrentUser, isLoggedIn } from '../../types/users'; @@ -34,13 +38,22 @@ export enum NewCodeDefinitionLevels { export type PreviouslyNonCompliantNCD = NewCodeDefinition & Required>; +export type PreviouslyNonCompliantBranchNCD = PreviouslyNonCompliantNCD & NewCodeDefinitionBranch; + export function isPreviouslyNonCompliantDaysNCD( newCodeDefinition: NewCodeDefinition -): newCodeDefinition is PreviouslyNonCompliantNCD { +): newCodeDefinition is PreviouslyNonCompliantNCD; +export function isPreviouslyNonCompliantDaysNCD( + newCodeDefinition: NewCodeDefinitionBranch +): newCodeDefinition is PreviouslyNonCompliantBranchNCD; +export function isPreviouslyNonCompliantDaysNCD( + newCodeDefinition: NewCodeDefinition | NewCodeDefinitionBranch +): newCodeDefinition is PreviouslyNonCompliantNCD | PreviouslyNonCompliantBranchNCD { return ( newCodeDefinition.type === NewCodeDefinitionType.NumberOfDays && newCodeDefinition.previousNonCompliantValue !== undefined && - newCodeDefinition.updatedAt !== undefined + newCodeDefinition.updatedAt !== undefined && + !newCodeDefinition.inherited ); } 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 cb846a32df7..cfd279ce06e 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3961,6 +3961,8 @@ new_code_definition.reference_branch.description=Choose a branch as the baseline new_code_definition.reference_branch.usecase=Recommended for projects using feature branches. new_code_definition.reference_branch.notice=The main branch will be set as the reference branch when the project is created. You will be able to choose another branch as the reference branch when your project will have more branches. +new_code_definition.auto_update.branch.message=The new code definition of the following branch(es) was automatically changed on {date}, following a SonarQube upgrade, as it was exceeding the maximum value: {branchesList} {link} +new_code_definition.auto_update.branch.list_item={branchName}: Number of days was changed from {previousDays} to {days}. new_code_definition.auto_update.global.message=The global new code definition was automatically changed from {previousDays} to {days} days on {date}, following a SonarQube upgrade, as it was exceeding the maximum value. {link} new_code_definition.auto_update.ncd_page.message=The number of days was automatically changed from {previousDays} to {days} on {date}, following a SonarQube upgrade, as it was exceeding the maximum value. {link} new_code_definition.auto_update.project.message=This project's new code definition was automatically changed from {previousDays} to {days} days on {date}, following a SonarQube upgrade, as it was exceeding the maximum value. {link} -- 2.39.5