]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20156 Display global banner on the UI for automatically changed NCD (#9078)
authorAndrey Luiz <andrey.luiz@sonarsource.com>
Thu, 17 Aug 2023 09:37:46 +0000 (11:37 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 24 Aug 2023 20:03:06 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/messages.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/MessagesServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/App.tsx
server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/components/new-code-definition/GlobalNCDAutoUpdateMessage.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/new-code-definition/__tests__/GlobalNCDAutoUpdateMessage-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/types/new-code-definition.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

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 (file)
index 0000000..c8c0bae
--- /dev/null
@@ -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<void> {
+  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 (file)
index 0000000..5243bae
--- /dev/null
@@ -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<T>(response: T): Promise<T> {
+    return Promise.resolve(cloneDeep(response));
+  }
+
+  reset = () => {
+    this.#messageResponse = {};
+  };
+}
index a6b27d7fe13b5cf085c085ec5c9bc004c13d09f1..374b3cd9e354232b882c18cf4f7101a35bd62a6f 100644 (file)
  */
 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<Props> {
   render() {
     return (
       <>
+        <GlobalNCDAutoUpdateMessage />
         <PageTracker>{this.renderPreconnectLink()}</PageTracker>
         <Outlet />
         <KeyboardShortcutsModal />
index 1937a131c9896c1c63104562885d51ffdf521033..50ce642e3cd873e10769ffd30334d8cf46251c57 100644 (file)
@@ -2,6 +2,7 @@
 
 exports[`should render correctly: default 1`] = `
 <Fragment>
+  <withCurrentUserContext(GlobalNCDAutoUpdateMessage) />
   <withRouter(withAppStateContext(PageTracker)) />
   <Outlet />
   <KeyboardShortcutsModal />
@@ -10,6 +11,7 @@ exports[`should render correctly: default 1`] = `
 
 exports[`should render correctly: with gravatar 1`] = `
 <Fragment>
+  <withCurrentUserContext(GlobalNCDAutoUpdateMessage) />
   <withRouter(withAppStateContext(PageTracker))>
     <link
       href="http://example.com"
diff --git a/server/sonar-web/src/main/js/components/new-code-definition/GlobalNCDAutoUpdateMessage.tsx b/server/sonar-web/src/main/js/components/new-code-definition/GlobalNCDAutoUpdateMessage.tsx
new file mode 100644 (file)
index 0000000..f61e09d
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * 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);
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 (file)
index 0000000..1dee881
--- /dev/null
@@ -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('/', () => (
+    <>
+      <Route path="/" element={<GlobalNCDAutoUpdateMessage currentUser={currentUser} />} />
+      <Route path="/admin/settings" element={<div>Admin NCD</div>} />
+    </>
+  ));
+}
index 9bda3b524328c561a6c2a81b0e6b75fa38f1a1ea..a0b12140820d5b48160c6293beb5fa339546f88a 100644 (file)
@@ -31,6 +31,8 @@ export interface NewCodeDefinition {
   value?: string;
   effectiveValue?: string;
   inherited?: boolean;
+  previousNonCompliantValue?: string;
+  updatedAt?: number;
 }
 
 export interface NewCodeDefinitiondWithCompliance {
index db00e9dd67e74fea4496ecbc3625a0fadb9e7e67..135444d1cf7ed51d43ca9e9672c704322714b35b 100644 (file)
@@ -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