@@ -1,207 +0,0 @@ | |||
/* | |||
* 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 React from 'react'; | |||
import { mockCurrentUser } from '../../../helpers/testMocks'; | |||
import { NoticeType } from '../../../types/users'; | |||
import { | |||
branchHandler, | |||
componentsHandler, | |||
issuesHandler, | |||
renderIssueApp, | |||
renderProjectIssuesApp, | |||
ui, | |||
usersHandler, | |||
} from '../test-utils'; | |||
jest.mock('../sidebar/Sidebar', () => { | |||
const fakeSidebar = () => { | |||
return <div data-guiding-id="issue-5" />; | |||
}; | |||
return { | |||
__esModule: true, | |||
default: fakeSidebar, | |||
Sidebar: fakeSidebar, | |||
}; | |||
}); | |||
jest.mock('../../../components/common/ScreenPositionHelper', () => ({ | |||
__esModule: true, | |||
default: class ScreenPositionHelper extends React.Component<{ | |||
children: (args: { top: number }) => React.ReactNode; | |||
}> { | |||
render() { | |||
// eslint-disable-next-line testing-library/no-node-access | |||
return this.props.children({ top: 10 }); | |||
} | |||
}, | |||
})); | |||
beforeEach(() => { | |||
issuesHandler.reset(); | |||
componentsHandler.reset(); | |||
branchHandler.reset(); | |||
usersHandler.reset(); | |||
window.scrollTo = jest.fn(); | |||
window.HTMLElement.prototype.scrollTo = jest.fn(); | |||
}); | |||
it('should display guide', async () => { | |||
const user = userEvent.setup(); | |||
renderIssueApp( | |||
mockCurrentUser({ | |||
isLoggedIn: true, | |||
dismissedNotices: { [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true }, | |||
}), | |||
); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.1.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.1.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.1.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.2.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.2.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.2.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.3.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.3.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.3.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.4.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.4.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.4.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.5.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.5.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.5.5'); | |||
expect(ui.guidePopup.byRole('button', { name: 'Next' }).query()).not.toBeInTheDocument(); | |||
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', async () => { | |||
renderIssueApp( | |||
mockCurrentUser({ | |||
isLoggedIn: true, | |||
dismissedNotices: { | |||
[NoticeType.ISSUE_GUIDE]: true, | |||
[NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, | |||
}, | |||
}), | |||
); | |||
expect((await ui.issueItems.findAll()).length).toBeGreaterThan(0); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should skip guide', async () => { | |||
const user = userEvent.setup(); | |||
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'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.1.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'skip' }).get()); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should not show guide if issues need sync', async () => { | |||
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(); | |||
}); | |||
it('should not show guide if user is not logged in', async () => { | |||
renderIssueApp(mockCurrentUser({ isLoggedIn: false })); | |||
expect((await ui.issueItems.findAll()).length).toBeGreaterThan(0); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should not show guide if there are no issues', () => { | |||
issuesHandler.setIssueList([]); | |||
renderIssueApp(mockCurrentUser({ isLoggedIn: true })); | |||
expect(ui.loading.query()).not.toBeInTheDocument(); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should show guide on issue page', async () => { | |||
const user = userEvent.setup(); | |||
renderProjectIssuesApp( | |||
'project/issues?issues=issue11&open=issue11&id=myproject', | |||
undefined, | |||
mockCurrentUser({ isLoggedIn: true }), | |||
); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.1.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.2.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.3.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.4.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.5.5'); | |||
expect(ui.guidePopup.byRole('button', { name: 'Next' }).query()).not.toBeInTheDocument(); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'close' }).get()); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); |
@@ -0,0 +1,243 @@ | |||
/* | |||
* 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 React from 'react'; | |||
import { mockCurrentUser } from '../../../helpers/testMocks'; | |||
import { IssueTransition } from '../../../types/issues'; | |||
import { NoticeType } from '../../../types/users'; | |||
import { | |||
branchHandler, | |||
componentsHandler, | |||
issuesHandler, | |||
renderIssueApp, | |||
renderProjectIssuesApp, | |||
ui, | |||
usersHandler, | |||
} from '../test-utils'; | |||
jest.mock('../sidebar/Sidebar', () => { | |||
const fakeSidebar = () => { | |||
return <div data-guiding-id="issue-5" />; | |||
}; | |||
return { | |||
__esModule: true, | |||
default: fakeSidebar, | |||
Sidebar: fakeSidebar, | |||
}; | |||
}); | |||
jest.mock('../../../components/common/ScreenPositionHelper', () => ({ | |||
__esModule: true, | |||
default: class ScreenPositionHelper extends React.Component<{ | |||
children: (args: { top: number }) => React.ReactNode; | |||
}> { | |||
render() { | |||
// eslint-disable-next-line testing-library/no-node-access | |||
return this.props.children({ top: 10 }); | |||
} | |||
}, | |||
})); | |||
describe('issue guides', () => { | |||
beforeEach(() => { | |||
issuesHandler.reset(); | |||
componentsHandler.reset(); | |||
branchHandler.reset(); | |||
usersHandler.reset(); | |||
window.scrollTo = jest.fn(); | |||
window.HTMLElement.prototype.scrollTo = jest.fn(); | |||
}); | |||
describe('Issue Guide', () => { | |||
it('should display guide', async () => { | |||
const user = userEvent.setup(); | |||
renderIssueApp( | |||
mockCurrentUser({ | |||
isLoggedIn: true, | |||
dismissedNotices: { [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true }, | |||
}), | |||
); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.1.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.1.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.1.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.2.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.2.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.2.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.3.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.3.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.3.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.4.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.4.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.4.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.5.title'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.issue_list.5.content'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.5.5'); | |||
expect(ui.guidePopup.byRole('button', { name: 'Next' }).query()).not.toBeInTheDocument(); | |||
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', async () => { | |||
renderIssueApp( | |||
mockCurrentUser({ | |||
isLoggedIn: true, | |||
dismissedNotices: { | |||
[NoticeType.ISSUE_GUIDE]: true, | |||
[NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, | |||
}, | |||
}), | |||
); | |||
expect((await ui.issueItems.findAll()).length).toBeGreaterThan(0); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should skip guide', async () => { | |||
const user = userEvent.setup(); | |||
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'); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.1.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'skip' }).get()); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should not show guide if issues need sync', async () => { | |||
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(); | |||
}); | |||
it('should not show guide if user is not logged in', async () => { | |||
renderIssueApp(mockCurrentUser({ isLoggedIn: false })); | |||
expect((await ui.issueItems.findAll()).length).toBeGreaterThan(0); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should not show guide if there are no issues', () => { | |||
issuesHandler.setIssueList([]); | |||
renderIssueApp(mockCurrentUser({ isLoggedIn: true })); | |||
expect(ui.loading.query()).not.toBeInTheDocument(); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should show guide on issue page and save its step on opened issue', async () => { | |||
const user = userEvent.setup(); | |||
renderProjectIssuesApp('project/issues', undefined, mockCurrentUser({ isLoggedIn: true })); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.1.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.2.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.3.5'); | |||
await user.click(ui.issueItemAction1.get()); | |||
expect(await ui.guidePopup.find()).toHaveTextContent('guiding.step_x_of_y.3.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.4.5'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.5.5'); | |||
expect(ui.guidePopup.byRole('button', { name: 'Next' }).query()).not.toBeInTheDocument(); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'close' }).get()); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
}); | |||
describe('Issue new status and transition guide', () => { | |||
it('should save transition guide step', async () => { | |||
const user = userEvent.setup(); | |||
issuesHandler.list[0].issue.transitions = [ | |||
IssueTransition.Accept, | |||
IssueTransition.Confirm, | |||
IssueTransition.Resolve, | |||
IssueTransition.FalsePositive, | |||
IssueTransition.WontFix, | |||
]; | |||
renderProjectIssuesApp( | |||
'project/issues', | |||
undefined, | |||
mockCurrentUser({ isLoggedIn: true, dismissedNotices: { [NoticeType.ISSUE_GUIDE]: true } }), | |||
); | |||
expect(await ui.guidePopup.find()).toBeInTheDocument(); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
expect(ui.guidePopup.get()).toHaveTextContent('guiding.step_x_of_y.2.3'); | |||
await user.click(ui.issueItemAction1.get()); | |||
expect(await ui.guidePopup.find()).toHaveTextContent('guiding.step_x_of_y.2.3'); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'next' }).get()); | |||
await user.click(ui.guidePopup.byRole('button', { name: 'close' }).get()); | |||
expect(ui.guidePopup.query()).not.toBeInTheDocument(); | |||
}); | |||
}); | |||
}); |
@@ -18,8 +18,9 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { SpotlightTour, SpotlightTourStep } from 'design-system'; | |||
import React from 'react'; | |||
import React, { useState } from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { CallBackProps } from 'react-joyride'; | |||
import { dismissNotice } from '../../../api/users'; | |||
import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; | |||
import DocLink from '../../../components/common/DocLink'; | |||
@@ -32,11 +33,13 @@ interface Props { | |||
} | |||
const PLACEMENT_RIGHT = 'right'; | |||
const SESSION_STORAGE_KEY = 'issueCleanCodeGuideStep'; | |||
const EXTRA_DELAY = 50; | |||
export default function IssueGuide({ run }: Props) { | |||
const { currentUser, updateDismissedNotices } = React.useContext(CurrentUserContext); | |||
const [step, setStep] = useState(+(sessionStorage.getItem(SESSION_STORAGE_KEY) ?? 0)); | |||
const canRun = currentUser.isLoggedIn && !currentUser.dismissedNotices[NoticeType.ISSUE_GUIDE]; | |||
// IssueGuide can be called within context of a ScreenPositionHelper. When this happens, | |||
@@ -57,21 +60,44 @@ export default function IssueGuide({ run }: Props) { | |||
setStart(run); | |||
}, SCREEN_POSITION_COMPUTE_DELAY + EXTRA_DELAY); | |||
} | |||
}, [canRun, run, start, setStart]); | |||
}, [canRun, run, start]); | |||
React.useEffect(() => { | |||
if (start && canRun) { | |||
sessionStorage.setItem(SESSION_STORAGE_KEY, step.toString()); | |||
} | |||
}, [step, start, canRun]); | |||
if (!start || !canRun) { | |||
return null; | |||
} | |||
const onToggle = (props: { action: string }) => { | |||
if (props.action === 'reset') { | |||
dismissNotice(NoticeType.ISSUE_GUIDE) | |||
.then(() => { | |||
updateDismissedNotices(NoticeType.ISSUE_GUIDE, true); | |||
}) | |||
.catch(() => { | |||
/* noop */ | |||
}); | |||
const onToggle = (props: CallBackProps) => { | |||
switch (props.action) { | |||
case 'close': | |||
case 'skip': | |||
case 'reset': | |||
sessionStorage.removeItem(SESSION_STORAGE_KEY); | |||
dismissNotice(NoticeType.ISSUE_GUIDE) | |||
.then(() => { | |||
updateDismissedNotices(NoticeType.ISSUE_GUIDE, true); | |||
}) | |||
.catch(() => { | |||
/* noop */ | |||
}); | |||
break; | |||
case 'next': | |||
if (props.lifecycle === 'complete') { | |||
setStep(step + 1); | |||
} | |||
break; | |||
case 'prev': | |||
if (props.lifecycle === 'complete') { | |||
setStep(step - 1); | |||
} | |||
break; | |||
default: | |||
break; | |||
} | |||
}; | |||
@@ -144,6 +170,7 @@ export default function IssueGuide({ run }: Props) { | |||
steps={steps} | |||
run={run} | |||
continuous | |||
stepIndex={step} | |||
skipLabel={translate('skip')} | |||
backLabel={translate('go_back')} | |||
closeLabel={translate('close')} |
@@ -18,10 +18,9 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { SpotlightTour, SpotlightTourStep } from 'design-system'; | |||
import React from 'react'; | |||
import React, { useState } 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'; | |||
@@ -30,13 +29,6 @@ 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; | |||
@@ -45,15 +37,19 @@ interface Props { | |||
const PLACEMENT_RIGHT = 'right'; | |||
const DOC_LINK = '/user-guide/issues/#statuses'; | |||
export const SESSION_STORAGE_TRANSITION_GUIDE_KEY = 'issueNewStatusAndTransitionGuideStep'; | |||
const EXTRA_DELAY = 100; | |||
const GUIDE_WIDTH = 360; | |||
export default function IssueNewStatusAndTransitionGuide(props: Readonly<Props>) { | |||
const { run, issues } = props; | |||
const { run, issues, togglePopup } = props; | |||
const { currentUser, updateDismissedNotices } = useCurrentUser(); | |||
const { mutateAsync: dismissNotice } = useDismissNoticeMutation(); | |||
const intl = useIntl(); | |||
const [{ guideIsRunning, stepIndex }, { setPartialState, resetState }] = useAcceptGuideState(); | |||
const [step, setStep] = useState( | |||
+(sessionStorage.getItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY) ?? 0), | |||
); | |||
const [start, setStart] = React.useState(false); | |||
const issueWithAcceptTransition = issues.find((issue) => | |||
issue.transitions.includes(IssueTransition.Accept), | |||
@@ -71,67 +67,74 @@ export default function IssueNewStatusAndTransitionGuide(props: Readonly<Props>) | |||
// 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) { | |||
// Should start the tour if it is not started yet | |||
if (!start && 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 }); | |||
if (step !== 0) { | |||
togglePopup(issueWithAcceptTransition.key, 'transition', true); | |||
setTimeout(() => { | |||
setStart(run); | |||
}, 0); | |||
} else { | |||
setStart(run); | |||
} | |||
}, SCREEN_POSITION_COMPUTE_DELAY + EXTRA_DELAY); | |||
} | |||
}, [canRun, guideIsRunning, setPartialState, issueWithAcceptTransition]); | |||
}, [canRun, run, step, start, togglePopup, 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 (start && canRun) { | |||
sessionStorage.setItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY, step.toString()); | |||
} | |||
}, [step, start, canRun]); | |||
if (!issueWithAcceptTransition || !guideIsRunning) { | |||
if (!canRun || !start) { | |||
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 onToggle = (props: CallBackProps) => { | |||
const { action, lifecycle, index } = props; | |||
switch (action) { | |||
case 'close': | |||
case 'skip': | |||
case 'reset': | |||
togglePopup(issueWithAcceptTransition.key, 'transition', false); | |||
sessionStorage.removeItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY); | |||
dismissNotice(NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE) | |||
.then(() => { | |||
updateDismissedNotices(NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE, true); | |||
}) | |||
.catch(() => { | |||
/* noop */ | |||
}); | |||
break; | |||
case 'next': | |||
if (lifecycle === 'complete') { | |||
if (index === 0) { | |||
togglePopup(issueWithAcceptTransition.key, 'transition', true); | |||
setTimeout(() => { | |||
setStep(step + 1); | |||
}, 0); | |||
} else { | |||
setStep(step + 1); | |||
} | |||
} | |||
break; | |||
case 'prev': | |||
if (lifecycle === 'complete') { | |||
if (index === 1) { | |||
togglePopup(issueWithAcceptTransition.key, 'transition', false); | |||
} | |||
setStep(step - 1); | |||
} | |||
break; | |||
default: | |||
break; | |||
} | |||
}; | |||
@@ -173,10 +176,10 @@ export default function IssueNewStatusAndTransitionGuide(props: Readonly<Props>) | |||
return ( | |||
<SpotlightTour | |||
width={GUIDE_WIDTH} | |||
callback={handleTourCallback} | |||
callback={onToggle} | |||
steps={steps} | |||
stepIndex={stepIndex} | |||
run={guideIsRunning} | |||
stepIndex={step} | |||
run={run} | |||
continuous | |||
skipLabel={intl.formatMessage({ id: 'skip' })} | |||
backLabel={intl.formatMessage({ id: 'go_back' })} |
@@ -20,17 +20,23 @@ | |||
import { act } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import React from 'react'; | |||
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 { issuesHandler, ui } from '../test-utils'; | |||
import IssuesServiceMock from '../../../../api/mocks/IssuesServiceMock'; | |||
import UsersServiceMock from '../../../../api/mocks/UsersServiceMock'; | |||
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 { ui } from '../../test-utils'; | |||
import IssueNewStatusAndTransitionGuide from '../IssueNewStatusAndTransitionGuide'; | |||
const usersHandler = new UsersServiceMock(); | |||
const issuesHandler = new IssuesServiceMock(usersHandler); | |||
beforeEach(() => { | |||
usersHandler.reset(); | |||
issuesHandler.reset(); | |||
}); | |||
@@ -28,7 +28,7 @@ import { | |||
} from 'design-system'; | |||
import * as React from 'react'; | |||
import { addIssueComment, setIssueTransition } from '../../../api/issues'; | |||
import { useAcceptGuideState } from '../../../apps/issues/components/IssueNewStatusAndTransitionGuide'; | |||
import { SESSION_STORAGE_TRANSITION_GUIDE_KEY } from '../../../apps/issues/components/IssueNewStatusAndTransitionGuide'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { Issue } from '../../../types/types'; | |||
import StatusHelper from '../../shared/StatusHelper'; | |||
@@ -45,8 +45,8 @@ interface Props { | |||
export default function IssueTransition(props: Readonly<Props>) { | |||
const { isOpen, issue, onChange, togglePopup } = props; | |||
const [{ stepIndex: guideStepIndex, guideIsRunning }] = useAcceptGuideState(); | |||
const guideStepIndex = +(sessionStorage.getItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY) ?? 0); | |||
const guideIsRunning = sessionStorage.getItem(SESSION_STORAGE_TRANSITION_GUIDE_KEY) !== null; | |||
const [transitioning, setTransitioning] = React.useState(false); | |||
async function handleSetTransition(transition: string, comment?: string) { |