aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorstanislavh <stanislav.honcharov@sonarsource.com>2023-11-06 11:13:33 +0100
committersonartech <sonartech@sonarsource.com>2023-11-08 20:02:53 +0000
commit19c131bdfccb98c24bcf432c5e5968bc5d4ffd5c (patch)
tree459256e361a1f8a308e1d244737e11b979dab683 /server/sonar-web/src/main
parentc8d1b7eb2494d92f20fb8b498efdbb2e3f8ea12c (diff)
downloadsonarqube-19c131bdfccb98c24bcf432c5e5968bc5d4ffd5c.tar.gz
sonarqube-19c131bdfccb98c24bcf432c5e5968bc5d4ffd5c.zip
SONAR-20873 Create new education tour for accepting issues
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts8
-rw-r--r--server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts6
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssuesAppGuide-it.tsx31
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssuesNewStatusAndTransitionGuide-it.tsx174
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssueNewStatusAndTransitionGuide.tsx190
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/issues/test-utils.tsx14
-rw-r--r--server/sonar-web/src/main/js/apps/overview/pullRequests/SonarLintAd.tsx6
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueTransition.tsx33
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueTransitionItem.tsx10
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueTransitionOverlay.tsx40
-rw-r--r--server/sonar-web/src/main/js/queries/users.ts23
-rw-r--r--server/sonar-web/src/main/js/types/users.ts1
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',
}