]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23654 Mode Tour
authorViktor Vorona <viktor.vorona@sonarsource.com>
Wed, 20 Nov 2024 16:38:49 +0000 (17:38 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 26 Nov 2024 20:02:50 +0000 (20:02 +0000)
16 files changed:
server/sonar-web/public/images/mode-tour/step1.png [new file with mode: 0644]
server/sonar-web/public/images/mode-tour/step2.png [new file with mode: 0644]
server/sonar-web/public/images/mode-tour/step3.png [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
server/sonar-web/src/main/js/app/components/ModeTour.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/__tests__/ModeTour-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx
server/sonar-web/src/main/js/design-system/components/SpotlightTour.tsx
server/sonar-web/src/main/js/helpers/constants.ts
server/sonar-web/src/main/js/types/users.ts
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java
sonar-core/src/main/resources/org/sonar/l10n/core.properties
sonar-ws/src/testFixtures/java/org/sonarqube/ws/tester/UserTester.java

diff --git a/server/sonar-web/public/images/mode-tour/step1.png b/server/sonar-web/public/images/mode-tour/step1.png
new file mode 100644 (file)
index 0000000..c2fde69
Binary files /dev/null and b/server/sonar-web/public/images/mode-tour/step1.png differ
diff --git a/server/sonar-web/public/images/mode-tour/step2.png b/server/sonar-web/public/images/mode-tour/step2.png
new file mode 100644 (file)
index 0000000..0acbf1f
Binary files /dev/null and b/server/sonar-web/public/images/mode-tour/step2.png differ
diff --git a/server/sonar-web/public/images/mode-tour/step3.png b/server/sonar-web/public/images/mode-tour/step3.png
new file mode 100644 (file)
index 0000000..edbe1d9
Binary files /dev/null and b/server/sonar-web/public/images/mode-tour/step3.png differ
index ef24aadaf96791393e490c3bfbe709e6496e6368..bb12f43c094ecc792394600acb022a2b2b498399 100644 (file)
@@ -33,6 +33,7 @@ import IndexationContextProvider from './indexation/IndexationContextProvider';
 import IndexationNotification from './indexation/IndexationNotification';
 import LanguagesContextProvider from './languages/LanguagesContextProvider';
 import MetricsContextProvider from './metrics/MetricsContextProvider';
+import ModeTour from './ModeTour';
 import GlobalNav from './nav/global/GlobalNav';
 import PromotionNotification from './promotion-notification/PromotionNotification';
 import StartupModal from './StartupModal';
@@ -98,6 +99,7 @@ export default function GlobalContainer() {
                         <NCDAutoUpdateMessage />
                         <UpdateNotification dismissable />
                         <GlobalNav location={location} />
+                        <ModeTour />
                         <CalculationChangeMessage />
                         {/* The following is the portal anchor point for the component nav
                          * See ComponentContainer.tsx
diff --git a/server/sonar-web/src/main/js/app/components/ModeTour.tsx b/server/sonar-web/src/main/js/app/components/ModeTour.tsx
new file mode 100644 (file)
index 0000000..7c00716
--- /dev/null
@@ -0,0 +1,197 @@
+/*
+ * 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 { Button, ButtonVariety, Modal, ModalSize } from '@sonarsource/echoes-react';
+import { useContext, useEffect, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { CallBackProps } from 'react-joyride';
+import { SpotlightTour, SpotlightTourStep } from '~design-system';
+import { Image } from '~sonar-aligned/components/common/Image';
+import { dismissNotice } from '../../api/users';
+import DocumentationLink from '../../components/common/DocumentationLink';
+import { CustomEvents } from '../../helpers/constants';
+import { DocLink } from '../../helpers/doc-links';
+import { useStandardExperienceModeQuery } from '../../queries/mode';
+import { Permissions } from '../../types/permissions';
+import { NoticeType } from '../../types/users';
+import { useAppState } from './app-state/withAppStateContext';
+import { CurrentUserContext } from './current-user/CurrentUserContext';
+
+export default function ModeTour() {
+  const { currentUser, updateDismissedNotices } = useContext(CurrentUserContext);
+  const appState = useAppState();
+  const intl = useIntl();
+  const { data: isStandardMode } = useStandardExperienceModeQuery();
+  const [step, setStep] = useState(1);
+  const [runManually, setRunManually] = useState(false);
+
+  const steps: SpotlightTourStep[] = [
+    {
+      target: '[data-guiding-id="mode-tour-1"]',
+      content: intl.formatMessage(
+        { id: 'mode_tour.step4.description' },
+        {
+          mode: intl.formatMessage({
+            id: `settings.mode.${isStandardMode ? 'standard' : 'mqr'}.name`,
+          }),
+          p1: (text) => <p>{text}</p>,
+          p: (text) => <p className="sw-mt-2">{text}</p>,
+          b: (text) => <b>{text}</b>,
+        },
+      ),
+      title: intl.formatMessage({ id: 'mode_tour.step4.title' }),
+      placement: 'bottom',
+    },
+    {
+      target: '[data-guiding-id="mode-tour-2"]',
+      title: intl.formatMessage({ id: 'mode_tour.step5.title' }),
+      content: null,
+      placement: 'left',
+      hideFooter: true,
+    },
+  ];
+
+  const nextStep = () => {
+    if ((step === 3 && !isAdmin) || step === 4) {
+      document.dispatchEvent(new CustomEvent(CustomEvents.OpenHelpMenu));
+      setTimeout(() => setStep(5));
+    } else {
+      setStep(step + 1);
+    }
+  };
+
+  const dismissTour = () => {
+    document.dispatchEvent(new CustomEvent(CustomEvents.CloseHelpMenu));
+    setStep(6);
+    dismissNotice(NoticeType.MODE_TOUR)
+      .then(() => {
+        updateDismissedNotices(NoticeType.MODE_TOUR, true);
+      })
+      .catch(() => {
+        /* noop */
+      });
+  };
+
+  const onToggle = (props: CallBackProps) => {
+    switch (props.action) {
+      case 'close':
+      case 'skip':
+      case 'reset':
+        dismissTour();
+        break;
+      case 'next':
+        if (props.lifecycle === 'complete') {
+          nextStep();
+        }
+        break;
+      default:
+        break;
+    }
+  };
+
+  useEffect(() => {
+    const listener = () => {
+      setStep(1);
+      setRunManually(true);
+    };
+    document.addEventListener(CustomEvents.RunTourMode, listener);
+
+    return () => document.removeEventListener(CustomEvents.RunTourMode, listener);
+  }, []);
+
+  const isAdmin = currentUser.permissions?.global.includes(Permissions.Admin);
+  const isAdminOrQGAdmin =
+    isAdmin || currentUser.permissions?.global.includes(Permissions.QualityGateAdmin);
+
+  if (!runManually && (currentUser.dismissedNotices[NoticeType.MODE_TOUR] || !isAdminOrQGAdmin)) {
+    return null;
+  }
+
+  const maxSteps = isAdmin ? 4 : 3;
+
+  return (
+    <>
+      <Modal
+        size={ModalSize.Wide}
+        isOpen={step <= 3}
+        onOpenChange={(isOpen) => isOpen === false && dismissTour()}
+        title={
+          step < 4 &&
+          intl.formatMessage({ id: `mode_tour.step${step}.title` }, { version: appState.version })
+        }
+        content={
+          <>
+            {step < 4 && (
+              <>
+                <Image
+                  alt={intl.formatMessage({ id: `mode_tour.step${step}.img_alt` })}
+                  className="sw-w-full sw-mb-4"
+                  src={`/images/mode-tour/step${step}.png`}
+                />
+                {intl.formatMessage(
+                  { id: `mode_tour.step${step}.description` },
+                  {
+                    p1: (text) => <p>{text}</p>,
+                    p: (text) => <p className="sw-mt-4">{text}</p>,
+                    b: (text) => <b>{text}</b>,
+                  },
+                )}
+                <div className="sw-mt-6">
+                  <b>
+                    {intl.formatMessage({ id: 'guiding.step_x_of_y' }, { 0: step, 1: maxSteps })}
+                  </b>
+                </div>
+              </>
+            )}
+          </>
+        }
+        footerLink={
+          <DocumentationLink standalone to={DocLink.ModeMQR}>
+            {intl.formatMessage({ id: `mode_tour.link` })}
+          </DocumentationLink>
+        }
+        primaryButton={
+          <Button variety={ButtonVariety.Primary} onClick={nextStep}>
+            {intl.formatMessage({ id: step === 1 ? 'lets_go' : 'next' })}
+          </Button>
+        }
+        secondaryButton={
+          step === 1 && (
+            <Button variety={ButtonVariety.Default} onClick={dismissTour}>
+              {intl.formatMessage({ id: 'later' })}
+            </Button>
+          )
+        }
+      />
+      <SpotlightTour
+        callback={onToggle}
+        steps={steps}
+        run={step > 3}
+        continuous
+        showProgress={step !== 5}
+        stepIndex={step - 4}
+        nextLabel={intl.formatMessage({ id: 'next' })}
+        stepXofYLabel={(x: number) =>
+          intl.formatMessage({ id: 'guiding.step_x_of_y' }, { 0: x + 3, 1: maxSteps })
+        }
+      />
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/ModeTour-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/ModeTour-test.tsx
new file mode 100644 (file)
index 0000000..5c09dd0
--- /dev/null
@@ -0,0 +1,184 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+import { ModeServiceMock } from '../../../api/mocks/ModeServiceMock';
+import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock';
+import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
+import { mockAppState, mockCurrentUser, mockLocation } from '../../../helpers/testMocks';
+import { renderApp } from '../../../helpers/testReactTestingUtils';
+import { byRole } from '../../../sonar-aligned/helpers/testSelector';
+import { Permissions } from '../../../types/permissions';
+import { NoticeType } from '../../../types/users';
+import ModeTour from '../ModeTour';
+import GlobalNav from '../nav/global/GlobalNav';
+
+const ui = {
+  dialog: byRole('dialog'),
+  step1Dialog: byRole('dialog', { name: /mode_tour.step1.title/ }),
+  step2Dialog: byRole('dialog', { name: /mode_tour.step2.title/ }),
+  step3Dialog: byRole('dialog', { name: /mode_tour.step3.title/ }),
+  next: byRole('button', { name: 'next' }),
+  later: byRole('button', { name: 'later' }),
+  skip: byRole('button', { name: 'skip' }),
+  letsgo: byRole('button', { name: 'lets_go' }),
+  help: byRole('button', { name: 'help' }),
+  guidePopup: byRole('alertdialog'),
+  tourTrigger: byRole('menuitem', { name: 'mode_tour.name' }),
+};
+
+const settingsHandler = new SettingsServiceMock();
+const modeHandler = new ModeServiceMock();
+const usersHandler = new UsersServiceMock();
+
+beforeEach(() => {
+  settingsHandler.reset();
+  modeHandler.reset();
+  usersHandler.reset();
+});
+
+it('renders the tour for admin', async () => {
+  const user = userEvent.setup();
+  renderGlobalNav(mockCurrentUser({ permissions: { global: [Permissions.Admin] } }));
+  expect(ui.step1Dialog.get()).toBeInTheDocument();
+  expect(ui.later.get()).toBeInTheDocument();
+  expect(ui.next.query()).not.toBeInTheDocument();
+  expect(ui.letsgo.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.get()).toHaveTextContent('guiding.step_x_of_y.1.4');
+  await user.click(ui.letsgo.get());
+
+  expect(ui.step2Dialog.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.query()).not.toBeInTheDocument();
+  expect(ui.later.query()).not.toBeInTheDocument();
+  expect(ui.next.get()).toBeInTheDocument();
+  expect(ui.letsgo.query()).not.toBeInTheDocument();
+  expect(ui.step2Dialog.get()).toHaveTextContent('guiding.step_x_of_y.2.4');
+  await user.click(ui.next.get());
+
+  expect(ui.step3Dialog.get()).toBeInTheDocument();
+  expect(ui.step2Dialog.query()).not.toBeInTheDocument();
+  expect(ui.next.get()).toBeInTheDocument();
+  expect(ui.step3Dialog.get()).toHaveTextContent('guiding.step_x_of_y.3.4');
+  await user.click(ui.next.get());
+
+  expect(ui.dialog.query()).not.toBeInTheDocument();
+  expect(await ui.guidePopup.find()).toBeInTheDocument();
+  expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.4.4');
+  expect(ui.guidePopup.by(ui.next).get()).toBeInTheDocument();
+  expect(ui.tourTrigger.query()).not.toBeInTheDocument();
+  await user.click(ui.next.get());
+
+  expect(ui.tourTrigger.get()).toBeInTheDocument();
+  expect(await ui.guidePopup.find()).toBeInTheDocument();
+  expect(ui.guidePopup.query()).not.toHaveTextContent('guiding.step_x_of_y');
+  expect(ui.next.query()).not.toBeInTheDocument();
+  expect(ui.skip.get()).toBeInTheDocument();
+  await user.click(ui.skip.get());
+
+  expect(ui.tourTrigger.query()).not.toBeInTheDocument();
+  expect(ui.dialog.query()).not.toBeInTheDocument();
+
+  // replay the tour
+  await user.click(ui.help.get());
+  await user.click(ui.tourTrigger.get());
+  expect(ui.step1Dialog.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.get()).toHaveTextContent('guiding.step_x_of_y.1.4');
+});
+
+it('renders the tour for gateadmins', async () => {
+  const user = userEvent.setup();
+  renderGlobalNav(mockCurrentUser({ permissions: { global: [Permissions.QualityGateAdmin] } }));
+  expect(ui.step1Dialog.get()).toBeInTheDocument();
+  expect(ui.later.get()).toBeInTheDocument();
+  expect(ui.next.query()).not.toBeInTheDocument();
+  expect(ui.letsgo.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.get()).toHaveTextContent('guiding.step_x_of_y.1.3');
+  await user.click(ui.letsgo.get());
+
+  expect(ui.step2Dialog.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.query()).not.toBeInTheDocument();
+  expect(ui.later.query()).not.toBeInTheDocument();
+  expect(ui.next.get()).toBeInTheDocument();
+  expect(ui.letsgo.query()).not.toBeInTheDocument();
+  expect(ui.step2Dialog.get()).toHaveTextContent('guiding.step_x_of_y.2.3');
+  await user.click(ui.next.get());
+
+  expect(ui.step3Dialog.get()).toBeInTheDocument();
+  expect(ui.step2Dialog.query()).not.toBeInTheDocument();
+  expect(ui.next.get()).toBeInTheDocument();
+  expect(ui.step3Dialog.get()).toHaveTextContent('guiding.step_x_of_y.3.3');
+  await user.click(ui.next.get());
+
+  expect(ui.dialog.query()).not.toBeInTheDocument();
+  expect(ui.tourTrigger.get()).toBeInTheDocument();
+  expect(await ui.guidePopup.find()).toBeInTheDocument();
+  expect(ui.guidePopup.query()).not.toHaveTextContent('guiding.step_x_of_y');
+  expect(ui.next.query()).not.toBeInTheDocument();
+  expect(ui.skip.get()).toBeInTheDocument();
+  await user.click(ui.skip.get());
+
+  expect(ui.tourTrigger.query()).not.toBeInTheDocument();
+  expect(ui.dialog.query()).not.toBeInTheDocument();
+
+  // replay the tour
+  await user.click(ui.help.get());
+  await user.click(ui.tourTrigger.get());
+  expect(ui.step1Dialog.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.get()).toHaveTextContent('guiding.step_x_of_y.1.3');
+});
+
+it('should not render the tour for regular users', async () => {
+  const user = userEvent.setup();
+  renderGlobalNav(mockCurrentUser({ permissions: { global: [] } }));
+  expect(ui.dialog.query()).not.toBeInTheDocument();
+  await user.click(ui.help.get());
+  expect(ui.tourTrigger.query()).not.toBeInTheDocument();
+});
+
+it('should not render the tour if it is already dismissed', async () => {
+  const user = userEvent.setup();
+  renderGlobalNav(
+    mockCurrentUser({
+      permissions: { global: [Permissions.Admin] },
+      dismissedNotices: { [NoticeType.MODE_TOUR]: true },
+    }),
+  );
+  expect(ui.dialog.query()).not.toBeInTheDocument();
+  await user.click(ui.help.get());
+  expect(ui.tourTrigger.get()).toBeInTheDocument();
+
+  await user.click(ui.tourTrigger.get());
+  expect(ui.step1Dialog.get()).toBeInTheDocument();
+  expect(ui.step1Dialog.get()).toHaveTextContent('guiding.step_x_of_y.1.4');
+});
+
+function renderGlobalNav(currentUser = mockCurrentUser()) {
+  renderApp(
+    '/',
+    <>
+      <GlobalNav location={mockLocation()} />
+      <ModeTour />
+    </>,
+    {
+      currentUser,
+      appState: mockAppState({ canAdmin: currentUser.permissions?.global.includes('admin') }),
+    },
+  );
+}
index 86bbba863cf2bb8d542c2a078d1d93bd60450086..450be91202c0723a40766710cb990c862aceaefb 100644 (file)
@@ -133,6 +133,7 @@ class GlobalNavMenu extends React.PureComponent<Props> {
     return (
       <MainMenuItem>
         <NavLink
+          data-guiding-id="mode-tour-1"
           className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')}
           to="/admin/settings"
         >
index 9824cec5a8f7f0e7399764d9e1615035324ce1f0..051426aeef0788d78d4f02e4fd08ac0f73ac83d8 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { DropdownMenu } from '@sonarsource/echoes-react';
+import { DropdownMenu, IconSlideshow } from '@sonarsource/echoes-react';
 import * as React from 'react';
+import { useCurrentUser } from '../../app/components/current-user/CurrentUserContext';
+import { CustomEvents } from '../../helpers/constants';
 import { DocLink } from '../../helpers/doc-links';
 import { translate } from '../../helpers/l10n';
+import { Permissions } from '../../types/permissions';
 import { SuggestionLink } from '../../types/types';
 import { DocItemLink } from './DocItemLink';
 import { SuggestionsContext } from './SuggestionsContext';
@@ -44,12 +47,21 @@ function Suggestions({ suggestions }: Readonly<{ suggestions: SuggestionLink[] }
 
 export function EmbedDocsPopup() {
   const firstItemRef = React.useRef<HTMLAnchorElement>(null);
+  const { currentUser } = useCurrentUser();
   const { suggestions } = React.useContext(SuggestionsContext);
 
   React.useEffect(() => {
     firstItemRef.current?.focus();
   }, []);
 
+  const runModeTour = () => {
+    document.dispatchEvent(new CustomEvent(CustomEvents.RunTourMode));
+  };
+
+  const isAdminOrQGAdmin =
+    currentUser.permissions?.global.includes(Permissions.Admin) ||
+    currentUser.permissions?.global.includes(Permissions.QualityGateAdmin);
+
   return (
     <>
       {suggestions.length !== 0 && <Suggestions suggestions={suggestions} />}
@@ -83,6 +95,22 @@ export function EmbedDocsPopup() {
       </DropdownMenu.ItemLink>
 
       <DropdownMenu.ItemLink to="https://twitter.com/SonarQube">X @SonarQube</DropdownMenu.ItemLink>
+
+      {isAdminOrQGAdmin && (
+        <>
+          <DropdownMenu.Separator />
+
+          <DropdownMenu.GroupLabel>{translate('tours')}</DropdownMenu.GroupLabel>
+
+          <DropdownMenu.ItemButton
+            prefix={<IconSlideshow />}
+            data-guiding-id="mode-tour-2"
+            onClick={runModeTour}
+          >
+            {translate('mode_tour.name')}
+          </DropdownMenu.ItemButton>
+        </>
+      )}
     </>
   );
 }
index f2562e8ed8121f395c01c705f9f060830eb85b43..218a4e200e7c2fc690bd63d8e8ef7e6a1067f818 100644 (file)
@@ -25,15 +25,33 @@ import {
   DropdownMenuAlign,
   IconQuestionMark,
 } from '@sonarsource/echoes-react';
+import { useEffect, useState } from 'react';
+import { CustomEvents } from '../../helpers/constants';
 import { translate } from '../../helpers/l10n';
 import { EmbedDocsPopup } from './EmbedDocsPopup';
 
 export default function EmbedDocsPopupHelper() {
+  const [open, setOpen] = useState(false);
+
+  useEffect(() => {
+    const openListener = () => setOpen(true);
+    const closeListener = () => setOpen(false);
+    document.addEventListener(CustomEvents.OpenHelpMenu, openListener);
+    document.addEventListener(CustomEvents.CloseHelpMenu, closeListener);
+    return () => {
+      document.removeEventListener(CustomEvents.OpenHelpMenu, openListener);
+      document.addEventListener(CustomEvents.CloseHelpMenu, closeListener);
+    };
+  }, []);
+
   return (
     <div className="dropdown">
       <DropdownMenu.Root
         align={DropdownMenuAlign.End}
         id="help-menu-dropdown"
+        isOpen={open}
+        onOpen={() => setOpen(true)}
+        onClose={() => setOpen(false)}
         items={<EmbedDocsPopup />}
       >
         <ButtonIcon
index 857e167ac3c88904fa30ce7593cac314ad520aa6..ecfdd774044ca43d8f55f348b53a6a06e650b2ca 100644 (file)
@@ -183,33 +183,35 @@ function TooltipComponent({
         </div>
       )}
 
-      <div className="sw-flex sw-justify-between sw-items-center sw-mt-4">
-        {(stepXofYLabel || size > 1) && (
-          <strong>
-            {stepXofYLabel
-              ? stepXofYLabel(index + 1, size)
-              : intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })}
-          </strong>
-        )}
-        <span />
-        <div>
-          {index > 0 && (
-            <Button className="sw-mr-4" variety={ButtonVariety.DefaultGhost} {...backProps}>
-              {backProps.title}
-            </Button>
-          )}
-          {continuous && !isLastStep && (
-            <Button variety={ButtonVariety.Primary} {...primaryProps}>
-              {primaryProps.title}
-            </Button>
-          )}
-          {(!continuous || isLastStep) && (
-            <Button variety={ButtonVariety.Primary} {...closeProps}>
-              {closeProps.title}
-            </Button>
+      {!step.hideFooter && (
+        <div className="sw-flex sw-justify-between sw-items-center sw-mt-4">
+          {(stepXofYLabel || size > 1) && (
+            <strong>
+              {stepXofYLabel
+                ? stepXofYLabel(index + 1, size)
+                : intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })}
+            </strong>
           )}
+          <span />
+          <div>
+            {index > 0 && (
+              <Button className="sw-mr-4" variety={ButtonVariety.DefaultGhost} {...backProps}>
+                {backProps.title}
+              </Button>
+            )}
+            {continuous && !isLastStep && (
+              <Button variety={ButtonVariety.Primary} {...primaryProps}>
+                {primaryProps.title}
+              </Button>
+            )}
+            {(!continuous || isLastStep) && (
+              <Button variety={ButtonVariety.Primary} {...closeProps}>
+                {closeProps.title}
+              </Button>
+            )}
+          </div>
         </div>
-      </div>
+      )}
     </StyledPopupWrapper>
   );
 }
index 1caa9eba5782a91550ab620505191e3cb83e127e..1ed6542052148f290c68e624718df23d36741825 100644 (file)
@@ -284,3 +284,9 @@ export const IMPORT_COMPATIBLE_ALMS = [
 export const GRADLE_SCANNER_VERSION = '6.0.0.5145';
 
 export const ONE_SECOND = 1000;
+
+export enum CustomEvents {
+  OpenHelpMenu = 'open-help-menu',
+  CloseHelpMenu = 'close-help-menu',
+  RunTourMode = 'runTour-mode',
+}
index d987d6a22887ea913a9cd05fdb50b097cd604f95..d43269c80a7db079a10e8ff1929a4115317fc1a5 100644 (file)
@@ -39,6 +39,7 @@ export enum NoticeType {
   OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = 'overviewZeroNewIssuesSimplification',
   ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE = 'onboardingDismissCaycBranchSummaryGuide',
   MQR_MODE_ADVERTISEMENT_BANNER = 'showNewModesBanner',
+  MODE_TOUR = 'showNewModesTour',
 }
 
 export interface LoggedInUser extends CurrentUser, UserActive {
index 41d34bd5a078849d1717d749104d833d178f800a..39366ec4fec936a70b714e14f3170dd0f18373e7 100644 (file)
@@ -79,7 +79,7 @@ public class DismissNoticeActionIT {
       .isInstanceOf(IllegalArgumentException.class)
       .hasMessage(
         "Value of parameter 'notice' (not_supported_value) must be one of: [educationPrinciples, sonarlintAd, issueCleanCodeGuide, qualityGateCaYCConditionsSimplification, " +
-          "overviewZeroNewIssuesSimplification, issueNewIssueStatusAndTransitionGuide, onboardingDismissCaycBranchSummaryGuide]");
+          "overviewZeroNewIssuesSimplification, issueNewIssueStatusAndTransitionGuide, onboardingDismissCaycBranchSummaryGuide, showNewModesTour, showNewModesBanner]");
   }
 
   @Test
index cd6450fa0e22a70bfa63be27b79953681b9a22c2..2012a601f283d5512778d656d9ceaf3bdcf0c749 100644 (file)
@@ -45,7 +45,7 @@ public class DismissNoticeAction implements UsersWsAction {
   private static final String SHOW_NEW_MODES_BANNER = "showNewModesBanner";
 
   protected static final List<String> AVAILABLE_NOTICE_KEYS = List.of(EDUCATION_PRINCIPLES, SONARLINT_AD, ISSUE_CLEAN_CODE_GUIDE, QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION,
-    OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION, ISSUE_NEW_ISSUE_STATUS_AND_TRANSITION_GUIDE, ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE);
+    OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION, ISSUE_NEW_ISSUE_STATUS_AND_TRANSITION_GUIDE, ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE, SHOW_NEW_MODES_TOUR, SHOW_NEW_MODES_BANNER);
   public static final String USER_DISMISS_CONSTANT = "user.dismissedNotices.";
   public static final String SUPPORT_FOR_NEW_NOTICE_MESSAGE = "Support for new notice '%s' was added.";
 
index 9b12f7276152ad27fe97bc738ee6864be35c92e7..d36cc894036f977ba104542a439c74ee61326054 100644 (file)
@@ -120,11 +120,13 @@ issues=Issues
 inheritance=Inheritance
 internal=internal
 key=Key
+later=Later
 language=Language
 last_analysis=Last Analysis
 learn_more=Learn More
 learn_more_x=Learn More: {link}
 learn_more.clean_code=Learn more: Clean as You Code
+lets_go=Let's go
 library=Library
 line_number=Line Number
 links=Links
@@ -253,6 +255,7 @@ title=Title
 to=To
 to_=to
 total=Total
+tours=Tours
 treemap=Treemap
 true=True
 type=Type
@@ -5899,3 +5902,24 @@ component_report.unsubscribe_success=Subscription successfully canceled. You won
 #
 #------------------------------------------------------------------------------
 guiding.step_x_of_y={0} of {1}
+
+#------------------------------------------------------------------------------
+#
+# MODE TOUR
+#
+#------------------------------------------------------------------------------
+mode_tour.name=Standard Experience and MQR mode tour
+mode_tour.link=Learn more about the modes in documentation
+mode_tour.step1.title=Welcome to SonarQube Server {version}!
+mode_tour.step1.img_alt=Visual presentation of the difference of the issue in the two new modes
+mode_tour.step1.description=<p1>We have introduced two modes - <b>Standard Experience</b> and <b>Multi-Quality Rule (MQR) Mode</b> - to empower you and your team to either continue using familiar workflows or explore new concepts.</p1><p>Take this tour to see what is the difference is between them.</p>
+mode_tour.step2.title=Standard Experience
+mode_tour.step2.img_alt=Visual presentation of the new look of Standard Experience badges: Vulnerability, Bug and Code Smell
+mode_tour.step2.description=<p1>The Standard Experience encompasses the use of rule types such as <b>Vulnerabilities</b>, <b>Bugs</b>, and <b>Code Smells</b>. Each rule has a single type and severity level, which you can customize with appropriate permissions.</p1><p>This approach focuses on assigning severity to a rule based on the single software quality (e.g., Security, Reliability, or Maintainability) on which it has the most significant impact.</p>
+mode_tour.step3.title=Multi-Quality Rule Mode
+mode_tour.step3.img_alt=Visual presentation of the new Software Quality badges: Security, Reliability and Maintainability and their connection to the corresponding Standard Experience badges
+mode_tour.step3.description=<p1>The new Multi-Quality Rule Mode aims to more accurately represent an issue's impact on all software qualities. </p1><p>It does this by assigning a separate severity to a rule for each software quality (Security, Reliability, and Maintainability), which replaces the types (Vulnerabilities, Bugs, and Code Smells). You can customize the severity level with appropriate permissions.</p><p>This approach focuses on ensuring the impact on all software qualities is clear, not just the one most severely impacted.</p>
+mode_tour.step4.title=Switch modes
+mode_tour.step4.description=<p1>You are currently in <b>{mode}</b>.</p1><p>To change it, go to Administration > Configuration > General Settings > Mode.</p><p>Your instance will start in the mode that most closely resembles the software version you are upgrading from.</p>
+mode_tour.step5.title=You can replay the tour from the help section
+
index 350cb661a1a9f9f3eae6e8acea77d10c7d30c6b6..9e00fa7e58e9e434f8aec51f91b9480425b7b077 100644 (file)
@@ -29,7 +29,9 @@ import javax.annotation.Nullable;
 import org.sonarqube.ws.UserTokens;
 import org.sonarqube.ws.Users;
 import org.sonarqube.ws.Users.CreateWsResponse.User;
+import org.sonarqube.ws.client.HttpConnector;
 import org.sonarqube.ws.client.PostRequest;
+import org.sonarqube.ws.client.WsClientFactories;
 import org.sonarqube.ws.client.WsResponse;
 import org.sonarqube.ws.client.usergroups.AddUserRequest;
 import org.sonarqube.ws.client.users.ChangePasswordRequest;
@@ -126,6 +128,7 @@ public class UserTester {
       new org.sonarqube.ws.client.permissions.AddUserRequest()
         .setLogin(u.getLogin())
         .setPermission("admin"));
+    dismissModesTour(u);
     return u;
   }
 
@@ -137,9 +140,17 @@ public class UserTester {
     User user = generate(populators);
     session.wsClient().permissions().addUser(new org.sonarqube.ws.client.permissions.AddUserRequest().setLogin(user.getLogin()).setPermission("admin"));
     session.wsClient().userGroups().addUser(new AddUserRequest().setLogin(user.getLogin()).setName("sonar-administrators"));
+    dismissModesTour(user);
     return user;
   }
 
+  private void dismissModesTour(User user) {
+    WsClientFactories.getDefault().newClient(HttpConnector.newBuilder()
+      .url(session.wsClient().wsConnector().baseUrl())
+      .credentials(user.getLogin(), user.getLogin())
+      .build()).users().dismissNotice("showNewModesTour");
+  }
+
   public UsersService service() {
     return session.wsClient().users();
   }