diff options
author | Viktor Vorona <viktor.vorona@sonarsource.com> | 2024-11-20 17:38:49 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-11-26 20:02:50 +0000 |
commit | e7cfc7f04f41fa7dde069844cc80d710913cb63e (patch) | |
tree | 7cacc1b36353f5661dac17f4fa7f0a628a8de088 /server | |
parent | 25d891639704eee3fe00653f5f00e1519e76545e (diff) | |
download | sonarqube-e7cfc7f04f41fa7dde069844cc80d710913cb63e.tar.gz sonarqube-e7cfc7f04f41fa7dde069844cc80d710913cb63e.zip |
SONAR-23654 Mode Tour
Diffstat (limited to 'server')
14 files changed, 467 insertions, 28 deletions
diff --git a/server/sonar-web/public/images/mode-tour/step1.png b/server/sonar-web/public/images/mode-tour/step1.png Binary files differnew file mode 100644 index 00000000000..c2fde69108a --- /dev/null +++ b/server/sonar-web/public/images/mode-tour/step1.png diff --git a/server/sonar-web/public/images/mode-tour/step2.png b/server/sonar-web/public/images/mode-tour/step2.png Binary files differnew file mode 100644 index 00000000000..0acbf1f087a --- /dev/null +++ b/server/sonar-web/public/images/mode-tour/step2.png diff --git a/server/sonar-web/public/images/mode-tour/step3.png b/server/sonar-web/public/images/mode-tour/step3.png Binary files differnew file mode 100644 index 00000000000..edbe1d96be9 --- /dev/null +++ b/server/sonar-web/public/images/mode-tour/step3.png diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index ef24aadaf96..bb12f43c094 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -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 index 00000000000..7c00716a6f2 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/ModeTour.tsx @@ -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 index 00000000000..5c09dd0bb49 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/ModeTour-test.tsx @@ -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') }), + }, + ); +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx index 86bbba863cf..450be91202c 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx @@ -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" > diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx index 9824cec5a8f..051426aeef0 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx @@ -18,10 +18,13 @@ * 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> + </> + )} </> ); } diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx index f2562e8ed81..218a4e200e7 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx @@ -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 diff --git a/server/sonar-web/src/main/js/design-system/components/SpotlightTour.tsx b/server/sonar-web/src/main/js/design-system/components/SpotlightTour.tsx index 857e167ac3c..ecfdd774044 100644 --- a/server/sonar-web/src/main/js/design-system/components/SpotlightTour.tsx +++ b/server/sonar-web/src/main/js/design-system/components/SpotlightTour.tsx @@ -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> ); } diff --git a/server/sonar-web/src/main/js/helpers/constants.ts b/server/sonar-web/src/main/js/helpers/constants.ts index 1caa9eba578..1ed65420521 100644 --- a/server/sonar-web/src/main/js/helpers/constants.ts +++ b/server/sonar-web/src/main/js/helpers/constants.ts @@ -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', +} diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index d987d6a2288..d43269c80a7 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -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 { diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java index 41d34bd5a07..39366ec4fec 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java @@ -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 diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java index cd6450fa0e2..2012a601f28 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java @@ -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."; |