--- /dev/null
+/*
+ * 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<void> {
+ return postJSON('api/dismiss_message/dismiss', data).catch(throwGlobalError);
+}
--- /dev/null
+/*
+ * 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<T>(response: T): Promise<T> {
+ return Promise.resolve(cloneDeep(response));
+ }
+
+ reset = () => {
+ this.#messageResponse = {};
+ };
+}
*/
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;
render() {
return (
<>
+ <GlobalNCDAutoUpdateMessage />
<PageTracker>{this.renderPreconnectLink()}</PageTracker>
<Outlet />
<KeyboardShortcutsModal />
exports[`should render correctly: default 1`] = `
<Fragment>
+ <withCurrentUserContext(GlobalNCDAutoUpdateMessage) />
<withRouter(withAppStateContext(PageTracker)) />
<Outlet />
<KeyboardShortcutsModal />
exports[`should render correctly: with gravatar 1`] = `
<Fragment>
+ <withCurrentUserContext(GlobalNCDAutoUpdateMessage) />
<withRouter(withAppStateContext(PageTracker))>
<link
href="http://example.com"
--- /dev/null
+/*
+ * 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 { Banner } from 'design-system';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { MessageTypes, checkMessageDismissed, setMessageDismissed } from '../../api/messages';
+import { getNewCodePeriod } from '../../api/newCodePeriod';
+import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext';
+import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
+import { NEW_CODE_PERIOD_CATEGORY } from '../../apps/settings/constants';
+import { translate } from '../../helpers/l10n';
+import { queryToSearch } from '../../helpers/urls';
+import { hasGlobalPermission } from '../../helpers/users';
+import { NewCodeDefinition, NewCodeDefinitionType } from '../../types/new-code-definition';
+import { Permissions } from '../../types/permissions';
+import { isLoggedIn } from '../../types/users';
+import Link from '../common/Link';
+
+interface Props extends Pick<CurrentUserContextInterface, 'currentUser'> {}
+
+export function GlobalNCDAutoUpdateMessage(props: Props) {
+ const { currentUser } = props;
+
+ const [newCodeDefinition, setNewCodeDefinition] = useState<NewCodeDefinition | undefined>(
+ 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 (
+ <Banner onDismiss={handleBannerDismiss} variant="info">
+ <FormattedMessage
+ defaultMessage="new_code_definition.auto_update.message"
+ id="new_code_definition.auto_update.message"
+ tagName="span"
+ values={{
+ previousDays: newCodeDefinition.previousNonCompliantValue,
+ days: newCodeDefinition.value,
+ date: new Date(newCodeDefinition.updatedAt).toLocaleDateString(),
+ link: (
+ <Link
+ to={{
+ pathname: '/admin/settings',
+ search: queryToSearch({
+ category: NEW_CODE_PERIOD_CATEGORY,
+ }),
+ }}
+ >
+ {translate('new_code_definition.auto_update.review_link')}
+ </Link>
+ ),
+ }}
+ />
+ </Banner>
+ );
+}
+
+export default withCurrentUserContext(GlobalNCDAutoUpdateMessage);
--- /dev/null
+/*
+ * 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('/', () => (
+ <>
+ <Route path="/" element={<GlobalNCDAutoUpdateMessage currentUser={currentUser} />} />
+ <Route path="/admin/settings" element={<div>Admin NCD</div>} />
+ </>
+ ));
+}
value?: string;
effectiveValue?: string;
inherited?: boolean;
+ previousNonCompliantValue?: string;
+ updatedAt?: number;
}
export interface NewCodeDefinitiondWithCompliance {
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