diff options
author | Viktor Vorona <viktor.vorona@sonarsource.com> | 2024-11-22 13:34:08 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-11-26 20:02:50 +0000 |
commit | 25d891639704eee3fe00653f5f00e1519e76545e (patch) | |
tree | 93fa91efbc2de88dc440dbfca31bf22296e8013a /server | |
parent | e9fa793d858fbca201bae86c74852b3ace3999af (diff) | |
download | sonarqube-25d891639704eee3fe00653f5f00e1519e76545e.tar.gz sonarqube-25d891639704eee3fe00653f5f00e1519e76545e.zip |
SONAR-23652 Advertisements for admins in Issues, Rules, Administration pages
Diffstat (limited to 'server')
7 files changed, 272 insertions, 0 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/ModeServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ModeServiceMock.ts index a0ae06ec856..7b2d053deb8 100644 --- a/server/sonar-web/src/main/js/api/mocks/ModeServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/ModeServiceMock.ts @@ -43,6 +43,10 @@ export class ModeServiceMock { this.mode.mode = mode; }; + setModified = () => { + this.mode.modified = true; + }; + handleGetMode: typeof getMode = () => { return this.reply(this.mode); }; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx index 213a9317b3e..f3d6730d722 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx @@ -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> ); diff --git a/server/sonar-web/src/main/js/apps/settings/components/SettingsAppRenderer.tsx b/server/sonar-web/src/main/js/apps/settings/components/SettingsAppRenderer.tsx index ac2befdba17..9f84421ed72 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/SettingsAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/SettingsAppRenderer.tsx @@ -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 index 00000000000..ec7143278f5 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/ModeBanner.tsx @@ -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 index 00000000000..57e136a35f6 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/ModeBanner-test.tsx @@ -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 }); +} diff --git a/server/sonar-web/src/main/js/queries/mode.ts b/server/sonar-web/src/main/js/queries/mode.ts index 857ff438235..2cc9e955ce5 100644 --- a/server/sonar-web/src/main/js/queries/mode.ts +++ b/server/sonar-web/src/main/js/queries/mode.ts @@ -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(); diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index e5f889627a7..d987d6a2288 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -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 { |