From 4e03fe0af252efc3f8c084d6a21f16a7f9d96f23 Mon Sep 17 00:00:00 2001 From: Andrey Luiz Date: Thu, 17 Aug 2023 11:37:46 +0200 Subject: [PATCH] SONAR-20156 Display global banner on the UI for automatically changed NCD (#9078) --- server/sonar-web/src/main/js/api/messages.ts | 42 ++++++ .../main/js/api/mocks/MessagesServiceMock.ts | 89 ++++++++++++ .../src/main/js/app/components/App.tsx | 4 +- .../__tests__/__snapshots__/App-test.tsx.snap | 2 + .../GlobalNCDAutoUpdateMessage.tsx | 108 +++++++++++++++ .../GlobalNCDAutoUpdateMessage-test.tsx | 129 ++++++++++++++++++ .../src/main/js/types/new-code-definition.ts | 2 + .../resources/org/sonar/l10n/core.properties | 3 + 8 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 server/sonar-web/src/main/js/api/messages.ts create mode 100644 server/sonar-web/src/main/js/api/mocks/MessagesServiceMock.ts create mode 100644 server/sonar-web/src/main/js/components/new-code-definition/GlobalNCDAutoUpdateMessage.tsx create mode 100644 server/sonar-web/src/main/js/components/new-code-definition/__tests__/GlobalNCDAutoUpdateMessage-test.tsx diff --git a/server/sonar-web/src/main/js/api/messages.ts b/server/sonar-web/src/main/js/api/messages.ts new file mode 100644 index 00000000000..c8c0bae154c --- /dev/null +++ b/server/sonar-web/src/main/js/api/messages.ts @@ -0,0 +1,42 @@ +/* + * 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 { throwGlobalError } from '../helpers/error'; +import { getJSON, postJSON } from '../helpers/request'; + +export enum MessageTypes { + GlobalNcd90 = 'global_ncd_90', + ProjectNcd90 = 'project_ncd_90', + BranchNcd90 = 'branch_ncd_90', +} + +export interface MessageDismissParams { + messageType: MessageTypes; + projectKey?: string; +} + +export function checkMessageDismissed(data: MessageDismissParams): Promise<{ + dismissed: boolean; +}> { + return getJSON('/api/dismiss_message/check', data).catch(throwGlobalError); +} + +export function setMessageDismissed(data: MessageDismissParams): Promise { + return postJSON('api/dismiss_message/dismiss', data).catch(throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/api/mocks/MessagesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/MessagesServiceMock.ts new file mode 100644 index 00000000000..5243bae6e82 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/MessagesServiceMock.ts @@ -0,0 +1,89 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { + checkMessageDismissed, + MessageDismissParams, + MessageTypes, + setMessageDismissed, +} from '../messages'; + +jest.mock('../messages'); + +interface Dismissed { + dismissed: boolean; +} + +interface ProjectDismissed { + [projectKey: string]: Dismissed; +} + +export default class MessagesServiceMock { + #messageResponse: { + [key in MessageTypes]?: ProjectDismissed | Dismissed; + }; + + constructor() { + this.#messageResponse = {}; + jest.mocked(checkMessageDismissed).mockImplementation(this.handleCheckMessageDismissed); + jest.mocked(setMessageDismissed).mockImplementation(this.handleSetMessageDismissed); + } + + handleCheckMessageDismissed = (data: MessageDismissParams) => { + const result = this.getMessageDismissed(data); + return this.reply(result as Dismissed); + }; + + handleSetMessageDismissed = (data: MessageDismissParams) => { + this.setMessageDismissed(data); + return Promise.resolve(); + }; + + setMessageDismissed = ({ projectKey, messageType }: MessageDismissParams) => { + if (projectKey) { + this.#messageResponse[messageType] ||= { + ...this.#messageResponse[messageType], + [projectKey]: { + dismissed: true, + }, + }; + } else { + this.#messageResponse[messageType] = { + ...this.#messageResponse[messageType], + dismissed: true, + }; + } + }; + + getMessageDismissed = ({ projectKey, messageType }: MessageDismissParams) => { + const dismissed = projectKey + ? (this.#messageResponse[messageType] as ProjectDismissed)?.[projectKey] + : this.#messageResponse[messageType]; + return dismissed || { dismissed: false }; + }; + + reply(response: T): Promise { + return Promise.resolve(cloneDeep(response)); + } + + reset = () => { + this.#messageResponse = {}; + }; +} diff --git a/server/sonar-web/src/main/js/app/components/App.tsx b/server/sonar-web/src/main/js/app/components/App.tsx index a6b27d7fe13..374b3cd9e35 100644 --- a/server/sonar-web/src/main/js/app/components/App.tsx +++ b/server/sonar-web/src/main/js/app/components/App.tsx @@ -19,11 +19,12 @@ */ import * as React from 'react'; import { Outlet } from 'react-router-dom'; +import GlobalNCDAutoUpdateMessage from '../../components/new-code-definition/GlobalNCDAutoUpdateMessage'; import { AppState } from '../../types/appstate'; import { GlobalSettingKeys } from '../../types/settings'; -import withAppStateContext from './app-state/withAppStateContext'; import KeyboardShortcutsModal from './KeyboardShortcutsModal'; import PageTracker from './PageTracker'; +import withAppStateContext from './app-state/withAppStateContext'; interface Props { appState: AppState; @@ -89,6 +90,7 @@ export class App extends React.PureComponent { render() { return ( <> + {this.renderPreconnectLink()} diff --git a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/App-test.tsx.snap index 1937a131c98..50ce642e3cd 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/App-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/App-test.tsx.snap @@ -2,6 +2,7 @@ exports[`should render correctly: default 1`] = ` + @@ -10,6 +11,7 @@ exports[`should render correctly: default 1`] = ` exports[`should render correctly: with gravatar 1`] = ` + {} + +export function GlobalNCDAutoUpdateMessage(props: Props) { + const { currentUser } = props; + + const [newCodeDefinition, setNewCodeDefinition] = useState( + undefined + ); + const [dismissed, setDismissed] = useState(false); + + const isSystemAdmin = useMemo( + () => isLoggedIn(currentUser) && hasGlobalPermission(currentUser, Permissions.Admin), + [currentUser] + ); + + useEffect(() => { + async function fetchNewCodeDefinition() { + const newCodeDefinition = await getNewCodePeriod(); + if ( + newCodeDefinition?.previousNonCompliantValue && + newCodeDefinition?.type === NewCodeDefinitionType.NumberOfDays + ) { + setNewCodeDefinition(newCodeDefinition); + const messageStatus = await checkMessageDismissed({ + messageType: MessageTypes.GlobalNcd90, + }); + setDismissed(messageStatus.dismissed); + } + } + + if (isSystemAdmin) { + fetchNewCodeDefinition(); + } + }, [isSystemAdmin]); + + const handleBannerDismiss = useCallback(async () => { + await setMessageDismissed({ messageType: MessageTypes.GlobalNcd90 }); + setDismissed(true); + }, []); + + if (!isSystemAdmin || !newCodeDefinition || dismissed || !newCodeDefinition.updatedAt) { + return null; + } + + return ( + + + {translate('new_code_definition.auto_update.review_link')} + + ), + }} + /> + + ); +} + +export default withCurrentUserContext(GlobalNCDAutoUpdateMessage); diff --git a/server/sonar-web/src/main/js/components/new-code-definition/__tests__/GlobalNCDAutoUpdateMessage-test.tsx b/server/sonar-web/src/main/js/components/new-code-definition/__tests__/GlobalNCDAutoUpdateMessage-test.tsx new file mode 100644 index 00000000000..1dee881ca72 --- /dev/null +++ b/server/sonar-web/src/main/js/components/new-code-definition/__tests__/GlobalNCDAutoUpdateMessage-test.tsx @@ -0,0 +1,129 @@ +/* + * 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 { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Route } from 'react-router-dom'; +import { MessageTypes } from '../../../api/messages'; +import MessagesServiceMock from '../../../api/mocks/MessagesServiceMock'; +import NewCodePeriodsServiceMock from '../../../api/mocks/NewCodePeriodsServiceMock'; +import { mockLoggedInUser } from '../../../helpers/testMocks'; +import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; +import { byRole, byText } from '../../../helpers/testSelector'; +import { NewCodeDefinitionType } from '../../../types/new-code-definition'; +import { GlobalNCDAutoUpdateMessage } from '../GlobalNCDAutoUpdateMessage'; + +let newCodeDefinitionMock: NewCodePeriodsServiceMock; +let messagesMock: MessagesServiceMock; + +beforeAll(() => { + newCodeDefinitionMock = new NewCodePeriodsServiceMock(); + messagesMock = new MessagesServiceMock(); +}); + +afterEach(() => { + newCodeDefinitionMock.reset(); + messagesMock.reset(); +}); + +const ui = { + message: byText(/new_code_definition.auto_update.message/), + dismissButton: byRole('button', { name: 'dismiss' }), + reviewLink: byText('new_code_definition.auto_update.review_link'), + adminNcdMessage: byText('Admin NCD'), +}; + +it('renders nothing if user is not admin', () => { + const { container } = renderMessage(mockLoggedInUser()); + expect(container).toBeEmptyDOMElement(); +}); + +it('renders message if user is admin', async () => { + newCodeDefinitionMock.setNewCodePeriod({ + type: NewCodeDefinitionType.NumberOfDays, + value: '90', + previousNonCompliantValue: '120', + updatedAt: 1692106874855, + }); + renderMessage(); + expect(await ui.message.find()).toBeVisible(); +}); + +it('dismisses message', async () => { + newCodeDefinitionMock.setNewCodePeriod({ + type: NewCodeDefinitionType.NumberOfDays, + value: '90', + previousNonCompliantValue: '120', + updatedAt: 1692106874855, + }); + renderMessage(); + expect(await ui.message.find()).toBeVisible(); + const user = userEvent.setup(); + await act(async () => { + await user.click(ui.dismissButton.get()); + }); + expect(ui.message.query()).not.toBeInTheDocument(); +}); + +it('does not render message if dismissed', () => { + newCodeDefinitionMock.setNewCodePeriod({ + type: NewCodeDefinitionType.NumberOfDays, + value: '90', + previousNonCompliantValue: '120', + updatedAt: 1692106874855, + }); + messagesMock.setMessageDismissed({ messageType: MessageTypes.GlobalNcd90 }); + renderMessage(); + expect(ui.message.query()).not.toBeInTheDocument(); +}); + +it('does not render message if new code definition has not been automatically updated', () => { + newCodeDefinitionMock.setNewCodePeriod({ + type: NewCodeDefinitionType.NumberOfDays, + value: '45', + }); + renderMessage(); + expect(ui.message.query()).not.toBeInTheDocument(); +}); + +it('clicking on review link redirects to NCD admin page', async () => { + newCodeDefinitionMock.setNewCodePeriod({ + type: NewCodeDefinitionType.NumberOfDays, + value: '90', + previousNonCompliantValue: '120', + updatedAt: 1692106874855, + }); + renderMessage(); + expect(await ui.message.find()).toBeVisible(); + const user = userEvent.setup(); + await act(async () => { + await user.click(ui.reviewLink.get()); + }); + expect(await ui.adminNcdMessage.find()).toBeVisible(); +}); + +function renderMessage(currentUser = mockLoggedInUser({ permissions: { global: ['admin'] } })) { + return renderAppRoutes('/', () => ( + <> + } /> + Admin NCD} /> + + )); +} diff --git a/server/sonar-web/src/main/js/types/new-code-definition.ts b/server/sonar-web/src/main/js/types/new-code-definition.ts index 9bda3b52432..a0b12140820 100644 --- a/server/sonar-web/src/main/js/types/new-code-definition.ts +++ b/server/sonar-web/src/main/js/types/new-code-definition.ts @@ -31,6 +31,8 @@ export interface NewCodeDefinition { value?: string; effectiveValue?: string; inherited?: boolean; + previousNonCompliantValue?: string; + updatedAt?: number; } export interface NewCodeDefinitiondWithCompliance { 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 db00e9dd67e..135444d1cf7 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,9 @@ 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.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.review_link=Review new code definition + #------------------------------------------------------------------------------ # # ONBOARDING -- 2.39.5