diff options
author | stanislavh <stanislav.honcharov@sonarsource.com> | 2023-11-06 11:13:33 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-11-08 20:02:53 +0000 |
commit | 19c131bdfccb98c24bcf432c5e5968bc5d4ffd5c (patch) | |
tree | 459256e361a1f8a308e1d244737e11b979dab683 /server/sonar-web/src/main | |
parent | c8d1b7eb2494d92f20fb8b498efdbb2e3f8ea12c (diff) | |
download | sonarqube-19c131bdfccb98c24bcf432c5e5968bc5d4ffd5c.tar.gz sonarqube-19c131bdfccb98c24bcf432c5e5968bc5d4ffd5c.zip |
SONAR-20873 Create new education tour for accepting issues
Diffstat (limited to 'server/sonar-web/src/main')
16 files changed, 515 insertions, 44 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts index 35ad8e2f3a3..8d126c0c2fc 100644 --- a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts @@ -494,7 +494,13 @@ export default class IssuesServiceMock { }; handleDismissNotification = (noticeType: NoticeType) => { - if ([NoticeType.EDUCATION_PRINCIPLES, NoticeType.ISSUE_GUIDE].includes(noticeType)) { + if ( + [ + NoticeType.EDUCATION_PRINCIPLES, + NoticeType.ISSUE_GUIDE, + NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE, + ].includes(noticeType) + ) { return this.reply(true); } diff --git a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts index ed6e75faa86..8ec34f4321b 100644 --- a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts +++ b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts @@ -39,13 +39,11 @@ export const CurrentUserContext = React.createContext<CurrentUserContextInterfac }); export function useCurrentUser() { - const { currentUser } = useContext(CurrentUserContext); - - return currentUser; + return useContext(CurrentUserContext); } export function useCurrentLoginUser() { - const currentUser = useCurrentUser(); + const { currentUser } = useCurrentUser(); if (!currentUser.isLoggedIn) { handleRequiredAuthentication(); diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesAppGuide-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesAppGuide-it.tsx index fe8b8d36bb2..93c5f8b24ea 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesAppGuide-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesAppGuide-it.tsx @@ -64,7 +64,12 @@ beforeEach(() => { it('should display guide', async () => { const user = userEvent.setup(); - renderIssueApp(mockCurrentUser({ isLoggedIn: true })); + renderIssueApp( + mockCurrentUser({ + isLoggedIn: true, + dismissedNotices: { [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true }, + }), + ); expect(await ui.guidePopup.find()).toBeInTheDocument(); @@ -106,7 +111,13 @@ it('should display guide', async () => { it('should not show guide for those who dismissed it', async () => { renderIssueApp( - mockCurrentUser({ isLoggedIn: true, dismissedNotices: { [NoticeType.ISSUE_GUIDE]: true } }), + mockCurrentUser({ + isLoggedIn: true, + dismissedNotices: { + [NoticeType.ISSUE_GUIDE]: true, + [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, + }, + }), ); expect((await ui.issueItems.findAll()).length).toBeGreaterThan(0); @@ -115,7 +126,12 @@ it('should not show guide for those who dismissed it', async () => { it('should skip guide', async () => { const user = userEvent.setup(); - renderIssueApp(mockCurrentUser({ isLoggedIn: true })); + renderIssueApp( + mockCurrentUser({ + isLoggedIn: true, + dismissedNotices: { [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true }, + }), + ); expect(await ui.guidePopup.find()).toBeInTheDocument(); expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.1.title'); @@ -127,7 +143,14 @@ it('should skip guide', async () => { }); it('should not show guide if issues need sync', async () => { - renderProjectIssuesApp(undefined, { needIssueSync: true }, mockCurrentUser({ isLoggedIn: true })); + renderProjectIssuesApp( + undefined, + { needIssueSync: true }, + mockCurrentUser({ + isLoggedIn: true, + dismissedNotices: { [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true }, + }), + ); expect((await ui.issueItems.findAll()).length).toBeGreaterThan(0); expect(ui.guidePopup.query()).not.toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesNewStatusAndTransitionGuide-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesNewStatusAndTransitionGuide-it.tsx new file mode 100644 index 00000000000..43708b804d5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesNewStatusAndTransitionGuide-it.tsx @@ -0,0 +1,174 @@ +/* + * 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 IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; +import CurrentUserContextProvider from '../../../app/components/current-user/CurrentUserContextProvider'; +import IssueTransitionComponent from '../../../components/issue/components/IssueTransition'; +import { mockCurrentUser, mockIssue } from '../../../helpers/testMocks'; +import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import { IssueTransition } from '../../../types/issues'; +import { Issue } from '../../../types/types'; +import { NoticeType } from '../../../types/users'; +import IssueNewStatusAndTransitionGuide from '../components/IssueNewStatusAndTransitionGuide'; +import { ui } from '../test-utils'; + +const issuesHandler = new IssuesServiceMock(); + +beforeEach(() => { + issuesHandler.reset(); +}); + +it('should display status guide', async () => { + const user = userEvent.setup(); + renderIssueNewStatusGuide(); + + expect(await ui.guidePopup.find()).toBeInTheDocument(); + expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_accept.1.title'); + + await act(async () => { + await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); + }); + + expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_accept.2.title'); + + await act(async () => { + await user.click(ui.guidePopup.byRole('button', { name: 'go_back' }).get()); + }); + expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_accept.1.title'); + + await act(async () => { + await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); + }); + await act(async () => { + await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); + }); + expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_accept.3.title'); + expect(ui.guidePopup.byRole('button', { name: 'Next' }).query()).not.toBeInTheDocument(); + + await act(async () => { + await user.click(ui.guidePopup.byRole('button', { name: 'close' }).get()); + }); + + expect(ui.guidePopup.query()).not.toBeInTheDocument(); +}); + +it('should not show guide for those who dismissed it', () => { + renderIssueNewStatusGuide( + mockCurrentUser({ + isLoggedIn: true, + dismissedNotices: { + [NoticeType.ISSUE_GUIDE]: true, + [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, + }, + }), + ); + + expect(ui.guidePopup.query()).not.toBeInTheDocument(); +}); + +it('should skip guide', async () => { + const user = userEvent.setup(); + renderIssueNewStatusGuide(); + + expect(await ui.guidePopup.find()).toBeInTheDocument(); + expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_accept.1.title'); + + await user.click(ui.guidePopup.byRole('button', { name: 'skip' }).get()); + + expect(ui.guidePopup.query()).not.toBeInTheDocument(); +}); + +it('should not show guide if user is not logged in', () => { + renderIssueNewStatusGuide(mockCurrentUser({ isLoggedIn: false })); + + expect(ui.guidePopup.query()).not.toBeInTheDocument(); +}); + +it('should not show guide if there are no issues', () => { + renderIssueNewStatusGuide(mockCurrentUser({ isLoggedIn: true }), []); + + expect(ui.guidePopup.query()).not.toBeInTheDocument(); +}); + +it('should not show guide if CCT guide is shown', () => { + renderIssueNewStatusGuide( + mockCurrentUser({ isLoggedIn: true, dismissedNotices: { [NoticeType.ISSUE_GUIDE]: false } }), + [], + ); + + expect(ui.guidePopup.query()).not.toBeInTheDocument(); +}); + +function IssueNewStatusGuide({ issues }: { issues: Issue[] }) { + const [open, setOpen] = React.useState(false); + const issue = mockIssue(false, { + transitions: [ + IssueTransition.Accept, + IssueTransition.Confirm, + IssueTransition.Resolve, + IssueTransition.FalsePositive, + IssueTransition.WontFix, + ], + }); + + return ( + <div data-guiding-id={`issue-transition-${issue.key}`}> + <div data-guiding-id="issue-accept-transition">/</div> + <IssueTransitionComponent + isOpen={open} + togglePopup={() => setOpen(!open)} + issue={issue} + onChange={jest.fn()} + /> + <IssueNewStatusAndTransitionGuide + togglePopup={(_, __, show) => setOpen(Boolean(show))} + run + issues={issues} + /> + </div> + ); +} + +function renderIssueNewStatusGuide( + currentUser = mockCurrentUser({ + isLoggedIn: true, + dismissedNotices: { [NoticeType.ISSUE_GUIDE]: true }, + }), + issues = [ + mockIssue(false, { + transitions: [ + IssueTransition.Accept, + IssueTransition.Confirm, + IssueTransition.Resolve, + IssueTransition.FalsePositive, + IssueTransition.WontFix, + ], + }), + ], +) { + return renderComponent( + <CurrentUserContextProvider currentUser={currentUser}> + <IssueNewStatusGuide issues={issues} /> + </CurrentUserContextProvider>, + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx index 4a151647f43..650efba2ac3 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx @@ -44,6 +44,7 @@ import { BranchLike } from '../../../types/branch-like'; import { IssueActions, IssueType } from '../../../types/issues'; import { Issue, RuleDetails } from '../../../types/types'; import IssueHeaderMeta from './IssueHeaderMeta'; +import IssueNewStatusAndTransitionGuide from './IssueNewStatusAndTransitionGuide'; interface Props { issue: Issue; @@ -212,6 +213,11 @@ export default class IssueHeader extends React.PureComponent<Props, State> { tagsPopupOpen={issuePopupName === 'edit-tags' && canSetTags} togglePopup={this.handleIssuePopupToggle} /> + <IssueNewStatusAndTransitionGuide + run + issues={[issue]} + togglePopup={(_, popup, show) => this.handleIssuePopupToggle(popup, show)} + /> </header> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueNewStatusAndTransitionGuide.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueNewStatusAndTransitionGuide.tsx new file mode 100644 index 00000000000..d071cd0c413 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/IssueNewStatusAndTransitionGuide.tsx @@ -0,0 +1,190 @@ +/* + * 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 { SpotlightTour, SpotlightTourStep } from 'design-system'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import { CallBackProps } from 'react-joyride'; +import { createSharedStoreHook } from 'shared-store-hook'; +import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; +import DocumentationLink from '../../../components/common/DocumentationLink'; +import { SCREEN_POSITION_COMPUTE_DELAY } from '../../../components/common/ScreenPositionHelper'; +import { useDismissNoticeMutation } from '../../../queries/users'; +import { IssueTransition } from '../../../types/issues'; +import { Issue } from '../../../types/types'; +import { NoticeType } from '../../../types/users'; + +export const useAcceptGuideState = createSharedStoreHook<{ + stepIndex: number; + guideIsRunning: boolean; +}>({ + initialState: { stepIndex: 0, guideIsRunning: false }, +}); + +interface Props { + run?: boolean; + togglePopup: (issue: string, popup: string, show?: boolean) => void; + issues: Issue[]; +} + +const PLACEMENT_RIGHT = 'right'; +const DOC_LINK = '/user-guide/issues/#statuses'; +const EXTRA_DELAY = 100; +const GUIDE_WIDTH = 360; + +export default function IssueNewStatusAndTransitionGuide(props: Readonly<Props>) { + const { run, issues } = props; + const { currentUser, updateDismissedNotices } = useCurrentUser(); + const { mutateAsync: dismissNotice } = useDismissNoticeMutation(); + const intl = useIntl(); + const [{ guideIsRunning, stepIndex }, { setPartialState, resetState }] = useAcceptGuideState(); + + const issueWithAcceptTransition = issues.find((issue) => + issue.transitions.includes(IssueTransition.Accept), + ); + + const userCompletedCCTGuide = + currentUser.isLoggedIn && currentUser.dismissedNotices[NoticeType.ISSUE_GUIDE]; + const userCompletedStatusGuide = + currentUser.isLoggedIn && + currentUser.dismissedNotices[NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]; + const canRun = + userCompletedCCTGuide && !userCompletedStatusGuide && run && issueWithAcceptTransition; + + // Wait for the issue list to be rendered, then scroll to the issue, wait for an extra delay + // to ensure proper positioning of the SpotlightTour in the context of ScreenPositionHelper, + // then start the tour. + React.useEffect(() => { + // If should start the tour and the tour is not started yet + if (!guideIsRunning && canRun) { + setTimeout(() => { + // Scroll to issue. This ensures proper rendering of the SpotlightTour. + document + .querySelector(`[data-guiding-id="issue-transition-${issueWithAcceptTransition.key}"]`) + ?.scrollIntoView({ behavior: 'instant', block: 'center' }); + // Start the tour + setPartialState({ guideIsRunning: true }); + }, SCREEN_POSITION_COMPUTE_DELAY + EXTRA_DELAY); + } + }, [canRun, guideIsRunning, setPartialState, issueWithAcceptTransition]); + + // We reset the state all the time so that the tour can be restarted when user revisits the page. + // This has effect only when user is ignored guide. + React.useEffect(() => { + return resetState; + }, [resetState]); + + if (!issueWithAcceptTransition || !guideIsRunning) { + return null; + } + + const dismissTour = async () => { + if (userCompletedStatusGuide) { + return; + } + + try { + await dismissNotice(NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE); + updateDismissedNotices(NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE, true); + } catch { + // ignore + } + }; + + const handleTourCallback = async ({ action, type, index }: CallBackProps) => { + if (type === 'step:after') { + // Open dropdown when going into step 1 and dismiss notice (we assume that the user has read the notice) + if (action === 'next' && index === 0) { + props.togglePopup(issueWithAcceptTransition.key, 'transition', true); + setTimeout(() => { + setPartialState({ stepIndex: index + 1 }); + dismissTour(); + }, 0); + return; + } + + // Close dropdown when going into step 0 from step 1 + if (action === 'prev' && index === 1) { + props.togglePopup(issueWithAcceptTransition.key, 'transition', false); + } + + setPartialState({ stepIndex: action === 'prev' ? index - 1 : index + 1 }); + return; + } + + // When the tour is finished or skipped. + if (action === 'reset' || action === 'skip' || action === 'close') { + props.togglePopup(issueWithAcceptTransition.key, 'transition', false); + await dismissTour(); + } + }; + + const constructContent = (stepIndex: number) => { + return ( + <> + <div className="sw-flex sw-flex-col sw-gap-4"> + <span>{intl.formatMessage({ id: `guiding.issue_accept.${stepIndex}.content.1` })}</span> + <span>{intl.formatMessage({ id: `guiding.issue_accept.${stepIndex}.content.2` })}</span> + </div> + <DocumentationLink to={DOC_LINK} className="sw-mt-1 sw-inline-block"> + {intl.formatMessage({ id: `guiding.issue_accept.${stepIndex}.content.link` })} + </DocumentationLink> + </> + ); + }; + + const steps: SpotlightTourStep[] = [ + { + target: `[data-guiding-id="issue-transition-${issueWithAcceptTransition.key}"]`, + title: intl.formatMessage({ id: 'guiding.issue_accept.1.title' }), + content: intl.formatMessage({ id: 'guiding.issue_accept.1.content.1' }), + placement: PLACEMENT_RIGHT, + }, + { + target: '[data-guiding-id="issue-accept-transition"]', + title: intl.formatMessage({ id: 'guiding.issue_accept.2.title' }), + content: constructContent(2), + placement: PLACEMENT_RIGHT, + }, + { + target: '[data-guiding-id="issue-deprecated-transitions"]', + title: intl.formatMessage({ id: 'guiding.issue_accept.3.title' }), + content: constructContent(3), + placement: PLACEMENT_RIGHT, + }, + ]; + + return ( + <SpotlightTour + width={GUIDE_WIDTH} + callback={handleTourCallback} + steps={steps} + stepIndex={stepIndex} + run={guideIsRunning} + continuous + skipLabel={intl.formatMessage({ id: 'skip' })} + backLabel={intl.formatMessage({ id: 'go_back' })} + closeLabel={intl.formatMessage({ id: 'close' })} + nextLabel={intl.formatMessage({ id: 'next' })} + stepXofYLabel={(x: number, y: number) => + intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': x, '1': y }) + } + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index fffb416f365..768480535f2 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -102,6 +102,7 @@ import { } from '../utils'; import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal'; import IssueGuide from './IssueGuide'; +import IssueNewStatusAndTransitionGuide from './IssueNewStatusAndTransitionGuide'; import IssueReviewHistoryAndComments from './IssueReviewHistoryAndComments'; import IssuesList from './IssuesList'; import IssuesSourceViewer from './IssuesSourceViewer'; @@ -1330,6 +1331,11 @@ export class App extends React.PureComponent<Props, State> { <> <Helmet defer={false} title={translate('issues.page')} /> <IssueGuide run={!open && !component?.needIssueSync && issues.length > 0} /> + <IssueNewStatusAndTransitionGuide + run={!open && !component?.needIssueSync && issues.length > 0} + togglePopup={this.handlePopupToggle} + issues={issues} + /> </> )} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx index 65364ec12cf..2a7a757becf 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx @@ -64,7 +64,7 @@ export interface Props { export function IssueSourceViewerHeader(props: Readonly<Props>) { const { component } = React.useContext(ComponentContext); const { data: branchData, isLoading: isLoadingBranches } = useBranchesQuery(component); - const currentUser = useCurrentUser(); + const { currentUser } = useCurrentUser(); const theme = useTheme(); const branchLike = branchData?.branchLike; diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx index 20514054359..f3a126c9962 100644 --- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx +++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx @@ -141,7 +141,12 @@ export async function waitOnDataLoaded() { } export function renderIssueApp( - currentUser = mockCurrentUser({ dismissedNotices: { [NoticeType.ISSUE_GUIDE]: true } }), + currentUser = mockCurrentUser({ + dismissedNotices: { + [NoticeType.ISSUE_GUIDE]: true, + [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, + }, + }), ) { renderApp('issues', <IssuesApp />, { currentUser }); } @@ -149,7 +154,12 @@ export function renderIssueApp( export function renderProjectIssuesApp( navigateTo?: string, overrides?: Partial<Component>, - currentUser = mockCurrentUser({ dismissedNotices: { [NoticeType.ISSUE_GUIDE]: true } }), + currentUser = mockCurrentUser({ + dismissedNotices: { + [NoticeType.ISSUE_GUIDE]: true, + [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, + }, + }), ) { renderAppWithComponentContext( 'project/issues', diff --git a/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx b/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx index d73a9261e4b..fda0a2dfe19 100644 --- a/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx +++ b/server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx @@ -45,7 +45,7 @@ const SONARLINT_PR_LS_KEY = 'sonarqube.pr_overview.show_sonarlint_promotion'; export default function SonarLintAd({ status }: Readonly<Props>) { const intl = useIntl(); - const user = useCurrentUser(); + const { currentUser } = useCurrentUser(); const [showSLPromotion, setSLPromotion] = useLocalStorage(SONARLINT_PR_LS_KEY, true); const onDismiss = React.useCallback(() => { @@ -53,8 +53,8 @@ export default function SonarLintAd({ status }: Readonly<Props>) { }, [setSLPromotion]); if ( - !isLoggedIn(user) || - user.usingSonarLintConnectedMode || + !isLoggedIn(currentUser) || + currentUser.usingSonarLintConnectedMode || status !== QGStatus.ERROR || !showSLPromotion ) { diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx index 6934289484d..28509e4fd79 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { HighlightRing } from 'design-system'; import * as React from 'react'; import { IssueActions } from '../../../types/issues'; import { Issue } from '../../../types/types'; @@ -64,14 +65,18 @@ export default function IssueActionsBar(props: Props) { return ( <div className="sw-flex sw-gap-3"> <ul className="it__issue-header-actions sw-flex sw-items-center sw-gap-3 sw-body-sm"> - <li className="sw-relative"> + <HighlightRing + as="li" + className="sw-relative" + data-guiding-id={`issue-transition-${issue.key}`} + > <IssueTransition isOpen={currentPopup === 'transition'} togglePopup={togglePopup} issue={issue} onChange={onChange} /> - </li> + </HighlightRing> <li> <IssueAssign diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTransition.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueTransition.tsx index 31a44cdadb8..1219d7b73ef 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTransition.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTransition.tsx @@ -18,9 +18,18 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Dropdown, PopupPlacement, PopupZLevel, SearchSelectDropdownControl } from 'design-system'; +import styled from '@emotion/styled'; +import { + Dropdown, + DropdownMenuWrapper, + ItemDivider, + PopupPlacement, + PopupZLevel, + SearchSelectDropdownControl, +} from 'design-system'; import * as React from 'react'; import { addIssueComment, setIssueTransition } from '../../../api/issues'; +import { useAcceptGuideState } from '../../../apps/issues/components/IssueNewStatusAndTransitionGuide'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { Issue } from '../../../types/types'; import StatusHelper from '../../shared/StatusHelper'; @@ -37,6 +46,8 @@ interface Props { export default function IssueTransition(props: Readonly<Props>) { const { isOpen, issue, onChange, togglePopup } = props; + const [{ stepIndex: guideStepIndex, guideIsRunning }] = useAcceptGuideState(); + const [transitioning, setTransitioning] = React.useState(false); async function handleSetTransition(transition: string, comment?: string) { @@ -65,18 +76,21 @@ export default function IssueTransition(props: Readonly<Props>) { if (issue.transitions?.length) { return ( - <Dropdown + <StyledDropdown allowResizing closeOnClick={false} id="issue-transition" onClose={handleClose} openDropdown={isOpen} + withClickOutHandler={!guideIsRunning} + withFocusOutHandler={!guideIsRunning} overlay={ <IssueTransitionOverlay issue={issue} onClose={handleClose} onSetTransition={handleSetTransition} loading={transitioning} + guideStepIndex={guideStepIndex} /> } placement={PopupPlacement.Bottom} @@ -99,9 +113,22 @@ export default function IssueTransition(props: Readonly<Props>) { )} /> )} - </Dropdown> + </StyledDropdown> ); } return <StatusHelper issueStatus={issue.issueStatus} />; } + +const StyledDropdown = styled(Dropdown)` + overflow: auto; + + & ${DropdownMenuWrapper} { + border-radius: 8px; + + ${ItemDivider} { + margin-left: 0; + margin-right: 0; + } + } +`; diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTransitionItem.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueTransitionItem.tsx index c689f78b02c..5632a884a8e 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTransitionItem.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTransitionItem.tsx @@ -34,15 +34,11 @@ import { IssueTransition } from '../../../types/issues'; type Props = { transition: IssueTransition; - selectedTransition?: IssueTransition; + selected: boolean; onSelectTransition: (transition: IssueTransition) => void; }; -export function IssueTransitionItem({ - transition, - selectedTransition, - onSelectTransition, -}: Readonly<Props>) { +export function IssueTransitionItem({ transition, selected, onSelectTransition }: Readonly<Props>) { const intl = useIntl(); const tooltips: Record<string, React.ReactFragment> = { @@ -67,7 +63,7 @@ export function IssueTransitionItem({ <ItemButton key={transition} onClick={() => onSelectTransition(transition)} - selected={selectedTransition === transition} + selected={selected} className="sw-px-4" > <div className="it__issue-transition-option sw-flex sw-flex-col"> diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTransitionOverlay.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueTransitionOverlay.tsx index f434902fa0f..201de78d272 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTransitionOverlay.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTransitionOverlay.tsx @@ -21,6 +21,7 @@ import { ButtonPrimary, ButtonSecondary, + HighlightRing, InputTextArea, ItemDivider, PageContentFontWrapper, @@ -39,12 +40,12 @@ export type Props = { issue: Pick<Issue, 'transitions' | 'actions'>; onClose: () => void; onSetTransition: (transition: IssueTransition, comment?: string) => void; + guideStepIndex: number; loading?: boolean; }; export function IssueTransitionOverlay(props: Readonly<Props>) { - const { issue, onClose, onSetTransition, loading } = props; - + const { issue, onClose, onSetTransition, loading, guideStepIndex } = props; const intl = useIntl(); const [comment, setComment] = useState(''); @@ -78,24 +79,33 @@ export function IssueTransitionOverlay(props: Readonly<Props>) { return ( <ul className="sw-flex sw-flex-col"> {filteredTransitionsRecommended.map((transition) => ( - <IssueTransitionItem + <HighlightRing key={transition} - transition={transition} - selectedTransition={selectedTransition} - onSelectTransition={selectTransition} - /> + data-guiding-id={transition === IssueTransition.Accept ? 'issue-accept-transition' : ''} + > + <IssueTransitionItem + transition={transition} + selected={ + selectedTransition === transition || + (guideStepIndex === 1 && transition === IssueTransition.Accept) + } + onSelectTransition={selectTransition} + /> + </HighlightRing> ))} {filteredTransitionsRecommended.length > 0 && filteredTransitionsDeprecated.length > 0 && ( <ItemDivider /> )} - {filteredTransitionsDeprecated.map((transition) => ( - <IssueTransitionItem - key={transition} - transition={transition} - selectedTransition={selectedTransition} - onSelectTransition={selectTransition} - /> - ))} + <HighlightRing data-guiding-id="issue-deprecated-transitions"> + {filteredTransitionsDeprecated.map((transition) => ( + <IssueTransitionItem + key={transition} + transition={transition} + selected={selectedTransition === transition || guideStepIndex === 2} + onSelectTransition={selectTransition} + /> + ))} + </HighlightRing> {selectedTransition && ( <> diff --git a/server/sonar-web/src/main/js/queries/users.ts b/server/sonar-web/src/main/js/queries/users.ts index 45ddf02c39b..6f0ceb63550 100644 --- a/server/sonar-web/src/main/js/queries/users.ts +++ b/server/sonar-web/src/main/js/queries/users.ts @@ -28,9 +28,17 @@ import { import { range } from 'lodash'; import { generateToken, getTokens, revokeToken } from '../api/user-tokens'; import { addUserToGroup, removeUserFromGroup } from '../api/user_groups'; -import { deleteUser, getUserGroups, getUsers, postUser, updateUser } from '../api/users'; +import { + deleteUser, + dismissNotice, + getUserGroups, + getUsers, + postUser, + updateUser, +} from '../api/users'; +import { useCurrentUser } from '../app/components/current-user/CurrentUserContext'; import { UserToken } from '../types/token'; -import { RestUserBase } from '../types/users'; +import { NoticeType, RestUserBase } from '../types/users'; const STALE_TIME = 4 * 60 * 1000; @@ -175,3 +183,14 @@ export function useRemoveUserToGroupMutation() { }, }); } + +export function useDismissNoticeMutation() { + const { updateDismissedNotices } = useCurrentUser(); + + return useMutation({ + mutationFn: (data: NoticeType) => dismissNotice(data), + onSuccess(_, data) { + updateDismissedNotices(data, true); + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index a292269835b..1fd4737cf1b 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -33,6 +33,7 @@ export enum NoticeType { EDUCATION_PRINCIPLES = 'educationPrinciples', SONARLINT_AD = 'sonarlintAd', ISSUE_GUIDE = 'issueCleanCodeGuide', + ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE = 'issueNewIssueStatusAndTransitionGuide', QG_CAYC_CONDITIONS_SIMPLIFICATION = 'qualityGateCaYCConditionsSimplification', OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = 'overviewZeroNewIssuesSimplification', } |