]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23652 Advertisements for admins in Issues, Rules, Administration pages
authorViktor Vorona <viktor.vorona@sonarsource.com>
Fri, 22 Nov 2024 12:34:08 +0000 (13:34 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 26 Nov 2024 20:02:50 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/mocks/ModeServiceMock.ts
server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx
server/sonar-web/src/main/js/apps/settings/components/SettingsAppRenderer.tsx
server/sonar-web/src/main/js/components/common/ModeBanner.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/ModeBanner-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/queries/mode.ts
server/sonar-web/src/main/js/types/users.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index a0ae06ec8564049f1e062fada0c2e366411d3124..7b2d053deb836872b0e589e30df84df3217fea89 100644 (file)
@@ -43,6 +43,10 @@ export class ModeServiceMock {
     this.mode.mode = mode;
   };
 
+  setModified = () => {
+    this.mode.modified = true;
+  };
+
   handleGetMode: typeof getMode = () => {
     return this.reply(this.mode);
   };
index 213a9317b3e04b53f3ae168f5d0b2b124525f66f..f3d6730d72289429abc406655366c2781d9954fb 100644 (file)
@@ -20,6 +20,7 @@
 
 import { Button, ButtonVariety } from '@sonarsource/echoes-react';
 import { BasicSeparator, PageTitle } from '~design-system';
+import ModeBanner from '../../../components/common/ModeBanner';
 import { translate } from '../../../helpers/l10n';
 
 interface Props {
@@ -40,6 +41,7 @@ export function FiltersHeader({ displayReset, onReset }: Props) {
         )}
       </div>
 
+      <ModeBanner as="facetBanner" />
       <BasicSeparator className="sw-mt-4" />
     </div>
   );
index ac2befdba177b1aa58660b26abc372e555832a1e..9f84421ed72dbe5c8f75444bd3f3dfaa8592c7f9 100644 (file)
@@ -25,6 +25,7 @@ import { Helmet } from 'react-helmet-async';
 import { LargeCenteredLayout, PageContentFontWrapper, themeBorder } from '~design-system';
 import { withRouter } from '~sonar-aligned/components/hoc/withRouter';
 import { Location } from '~sonar-aligned/types/router';
+import ModeBanner from '../../../components/common/ModeBanner';
 import { translate } from '../../../helpers/l10n';
 import { ExtendedSettingDefinition } from '../../../types/settings';
 import { Component } from '../../../types/types';
@@ -72,6 +73,8 @@ function SettingsAppRenderer(props: Readonly<SettingsAppRendererProps>) {
     <LargeCenteredLayout id="settings-page">
       <Helmet defer={false} title={translate('settings.page')} />
 
+      <ModeBanner as="wideBanner" />
+
       <PageContentFontWrapper className="sw-my-8">
         <PageHeader component={component} definitions={definitions} />
 
diff --git a/server/sonar-web/src/main/js/components/common/ModeBanner.tsx b/server/sonar-web/src/main/js/components/common/ModeBanner.tsx
new file mode 100644 (file)
index 0000000..ec71432
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 styled from '@emotion/styled';
+import {
+  ButtonIcon,
+  ButtonSize,
+  ButtonVariety,
+  IconX,
+  Link,
+  LinkHighlight,
+} from '@sonarsource/echoes-react';
+import { useIntl } from 'react-intl';
+import tw from 'twin.macro';
+import { dismissNotice } from '../../api/users';
+import { useCurrentUser } from '../../app/components/current-user/CurrentUserContext';
+import { Banner } from '../../design-system';
+import { useModeModifiedQuery, useStandardExperienceModeQuery } from '../../queries/mode';
+import { Permissions } from '../../types/permissions';
+import { NoticeType } from '../../types/users';
+
+interface Props {
+  as: 'facetBanner' | 'wideBanner';
+}
+
+export default function ModeBanner({ as }: Props) {
+  const intl = useIntl();
+  const { currentUser, updateDismissedNotices } = useCurrentUser();
+  const { data: isStandardMode } = useStandardExperienceModeQuery();
+  const { data: isModified, isLoading } = useModeModifiedQuery();
+
+  const onDismiss = () => {
+    dismissNotice(NoticeType.MQR_MODE_ADVERTISEMENT_BANNER)
+      .then(() => {
+        updateDismissedNotices(NoticeType.MQR_MODE_ADVERTISEMENT_BANNER, true);
+      })
+      .catch(() => {
+        /* noop */
+      });
+  };
+
+  if (
+    !currentUser.permissions?.global.includes(Permissions.Admin) ||
+    currentUser.dismissedNotices[NoticeType.MQR_MODE_ADVERTISEMENT_BANNER] ||
+    isLoading ||
+    isModified
+  ) {
+    return null;
+  }
+
+  return as === 'wideBanner' ? (
+    <Banner className="sw-mt-8" variant="info" onDismiss={onDismiss}>
+      <div>
+        {intl.formatMessage(
+          { id: `settings.mode.${isStandardMode ? 'standard' : 'mqr'}.advertisement` },
+          {
+            a: (text) => (
+              <Link highlight={LinkHighlight.CurrentColor} to="/admin/settings?category=mode">
+                {text}
+              </Link>
+            ),
+          },
+        )}
+      </div>
+    </Banner>
+  ) : (
+    <FacetBanner>
+      <div className="sw-flex sw-gap-2">
+        <div>
+          {intl.formatMessage(
+            { id: `mode.${isStandardMode ? 'standard' : 'mqr'}.advertisement` },
+            {
+              a: (text) => (
+                <Link highlight={LinkHighlight.CurrentColor} to="/admin/settings?category=mode">
+                  {text}
+                </Link>
+              ),
+            },
+          )}
+        </div>
+        <ButtonIcon
+          className="sw-flex-none"
+          Icon={IconX}
+          ariaLabel={intl.formatMessage({ id: 'dismiss' })}
+          onClick={onDismiss}
+          size={ButtonSize.Medium}
+          variety={ButtonVariety.DefaultGhost}
+        />
+      </div>
+    </FacetBanner>
+  );
+}
+
+const FacetBanner = styled.div`
+  ${tw`sw-p-2 sw-rounded-2`}
+  background-color: var(--echoes-color-background-accent-weak-default);
+`;
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/ModeBanner-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/ModeBanner-test.tsx
new file mode 100644 (file)
index 0000000..57e136a
--- /dev/null
@@ -0,0 +1,144 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ModeServiceMock } from '../../../api/mocks/ModeServiceMock';
+import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
+import { mockCurrentUser } from '../../../helpers/testMocks';
+import { renderComponent } from '../../../helpers/testReactTestingUtils';
+import { Mode } from '../../../types/mode';
+import { NoticeType } from '../../../types/users';
+import ModeBanner from '../ModeBanner';
+
+const modeHandler = new ModeServiceMock();
+const usersHandler = new UsersServiceMock();
+
+afterEach(() => {
+  modeHandler.reset();
+  usersHandler.reset();
+});
+
+describe('facetBanner', () => {
+  it('renders as facetBanner for admins in MQR', async () => {
+    const user = userEvent.setup();
+    modeHandler.setMode(Mode.MQR);
+    renderModeBanner(
+      { as: 'facetBanner' },
+      mockCurrentUser({ permissions: { global: ['admin'] } }),
+    );
+    expect(await screen.findByText('mode.mqr.advertisement')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'dismiss' }));
+    expect(screen.queryByText('mode.mqr.advertisement')).not.toBeInTheDocument();
+  });
+
+  it('renders as facetBanner for admins in Standard', async () => {
+    const user = userEvent.setup();
+    modeHandler.setMode(Mode.Standard);
+    renderModeBanner(
+      { as: 'facetBanner' },
+      mockCurrentUser({ permissions: { global: ['admin'] } }),
+    );
+    expect(await screen.findByText('mode.standard.advertisement')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'dismiss' }));
+    expect(screen.queryByText('mode.standard.advertisement')).not.toBeInTheDocument();
+  });
+
+  it('does not render as facetBanner for regular users', () => {
+    renderModeBanner({ as: 'facetBanner' }, mockCurrentUser());
+    expect(screen.queryByText(/advertisement/)).not.toBeInTheDocument();
+  });
+
+  it('does not render if already dismissed', () => {
+    renderModeBanner(
+      { as: 'facetBanner' },
+      mockCurrentUser({
+        permissions: { global: ['admin'] },
+        dismissedNotices: { [NoticeType.MQR_MODE_ADVERTISEMENT_BANNER]: true },
+      }),
+    );
+    expect(screen.queryByText(/advertisement/)).not.toBeInTheDocument();
+  });
+
+  it('does not render if modified', () => {
+    modeHandler.setModified();
+    renderModeBanner(
+      { as: 'facetBanner' },
+      mockCurrentUser({
+        permissions: { global: ['admin'] },
+      }),
+    );
+    expect(screen.queryByText(/advertisement/)).not.toBeInTheDocument();
+  });
+});
+
+describe('flagMessage', () => {
+  it('renders as flagMessage for admins in MQR', async () => {
+    const user = userEvent.setup();
+    modeHandler.setMode(Mode.MQR);
+    renderModeBanner({ as: 'wideBanner' }, mockCurrentUser({ permissions: { global: ['admin'] } }));
+    expect(await screen.findByText('settings.mode.mqr.advertisement')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'dismiss' }));
+    expect(screen.queryByText('settings.mode.mqr.advertisement')).not.toBeInTheDocument();
+  });
+
+  it('renders as flagMessage for admins in Standard', async () => {
+    const user = userEvent.setup();
+    modeHandler.setMode(Mode.Standard);
+    renderModeBanner({ as: 'wideBanner' }, mockCurrentUser({ permissions: { global: ['admin'] } }));
+    expect(await screen.findByText('settings.mode.standard.advertisement')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'dismiss' }));
+    expect(screen.queryByText('settings.mode.standard.advertisement')).not.toBeInTheDocument();
+  });
+
+  it('does not render as flagMessage for regular users', () => {
+    renderModeBanner({ as: 'wideBanner' }, mockCurrentUser());
+    expect(screen.queryByText(/advertisement/)).not.toBeInTheDocument();
+  });
+
+  it('does not render if already dismissed', () => {
+    renderModeBanner(
+      { as: 'wideBanner' },
+      mockCurrentUser({
+        permissions: { global: ['admin'] },
+        dismissedNotices: { [NoticeType.MQR_MODE_ADVERTISEMENT_BANNER]: true },
+      }),
+    );
+    expect(screen.queryByText(/advertisement/)).not.toBeInTheDocument();
+  });
+
+  it('does not render if modified', () => {
+    modeHandler.setModified();
+    renderModeBanner(
+      { as: 'wideBanner' },
+      mockCurrentUser({
+        permissions: { global: ['admin'] },
+      }),
+    );
+    expect(screen.queryByText(/advertisement/)).not.toBeInTheDocument();
+  });
+});
+
+function renderModeBanner(
+  props: Parameters<typeof ModeBanner>[0],
+  currentUser = mockCurrentUser(),
+) {
+  return renderComponent(<ModeBanner {...props} />, '/', { currentUser });
+}
index 857ff438235f25f23fa3943d7a0911b0448652bc..2cc9e955ce5e7277bfd91f27c564ce551398f7e9 100644 (file)
@@ -37,6 +37,10 @@ export const useStandardExperienceModeQuery = () => {
   return useModeQuery({ select: (data) => data.mode === Mode.Standard });
 };
 
+export const useModeModifiedQuery = () => {
+  return useModeQuery({ select: (data) => data.modified });
+};
+
 export function useUpdateModeMutation() {
   const queryClient = useQueryClient();
   const intl = useIntl();
index e5f889627a72239f5c95598c59e57117db89fd9f..d987d6a22887ea913a9cd05fdb50b097cd604f95 100644 (file)
@@ -38,6 +38,7 @@ export enum NoticeType {
   QG_CAYC_CONDITIONS_SIMPLIFICATION = 'qualityGateCaYCConditionsSimplification',
   OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = 'overviewZeroNewIssuesSimplification',
   ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE = 'onboardingDismissCaycBranchSummaryGuide',
+  MQR_MODE_ADVERTISEMENT_BANNER = 'showNewModesBanner',
 }
 
 export interface LoggedInUser extends CurrentUser, UserActive {
index cd2165b253d3dae09bb23c9455c65c8100d87bdd..9b12f7276152ad27fe97bc738ee6864be35c92e7 100644 (file)
@@ -1869,6 +1869,12 @@ settings.mode.save.warning=Save changes to see them reflected in your instance
 settings.mode.save=Save the mode. The current mode will be switched to {isStandardMode, select, true {Standard Experience} other {Multi-Quality Rule Mode}}
 settings.mode.save.success=This instance is now in {isStandardMode, select, true {Standard Experience} other {Multi-Quality Rule Mode}}.
 
+mode.standard.advertisement=Looking for Bugs, Vulnerabilities, or Code Smells? If your team prefers working with these types, change it in the <a>settings</a>
+mode.mqr.advertisement=Looking for Security, Reliability, and Maintainability issues? If your team prefers working with software qualities, change it in the <a>settings</a>
+settings.mode.mqr.advertisement=If your team prefers working with Vulnerabilities, Bugs, and Code Smells, change it in the <a>Mode section</a> of General Settings
+settings.mode.standard.advertisement=If your team prefers working with Security, Reliability, and Maintainability issues, change it in the <a>Mode section</a> of General Settings
+
+
 property.category.announcement=Announcement
 property.category.general=General
 property.category.general.email=Email