/* | |||||
* 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(); | |||||
}); |
/* | |||||
* 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(); | |||||
}); | |||||
}); | |||||
}); |
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { SpotlightTour, SpotlightTourStep } from 'design-system'; | import { SpotlightTour, SpotlightTourStep } from 'design-system'; | ||||
import React from 'react'; | |||||
import React, { useState } from 'react'; | |||||
import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||
import { CallBackProps } from 'react-joyride'; | |||||
import { dismissNotice } from '../../../api/users'; | import { dismissNotice } from '../../../api/users'; | ||||
import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; | import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; | ||||
import DocLink from '../../../components/common/DocLink'; | import DocLink from '../../../components/common/DocLink'; | ||||
} | } | ||||
const PLACEMENT_RIGHT = 'right'; | const PLACEMENT_RIGHT = 'right'; | ||||
const SESSION_STORAGE_KEY = 'issueCleanCodeGuideStep'; | |||||
const EXTRA_DELAY = 50; | const EXTRA_DELAY = 50; | ||||
export default function IssueGuide({ run }: Props) { | export default function IssueGuide({ run }: Props) { | ||||
const { currentUser, updateDismissedNotices } = React.useContext(CurrentUserContext); | 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]; | const canRun = currentUser.isLoggedIn && !currentUser.dismissedNotices[NoticeType.ISSUE_GUIDE]; | ||||
// IssueGuide can be called within context of a ScreenPositionHelper. When this happens, | // IssueGuide can be called within context of a ScreenPositionHelper. When this happens, | ||||
setStart(run); | setStart(run); | ||||
}, SCREEN_POSITION_COMPUTE_DELAY + EXTRA_DELAY); | }, 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) { | if (!start || !canRun) { | ||||
return null; | 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; | |||||
} | } | ||||
}; | }; | ||||
steps={steps} | steps={steps} | ||||
run={run} | run={run} | ||||
continuous | continuous | ||||
stepIndex={step} | |||||
skipLabel={translate('skip')} | skipLabel={translate('skip')} | ||||
backLabel={translate('go_back')} | backLabel={translate('go_back')} | ||||
closeLabel={translate('close')} | closeLabel={translate('close')} |
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { SpotlightTour, SpotlightTourStep } from 'design-system'; | import { SpotlightTour, SpotlightTourStep } from 'design-system'; | ||||
import React from 'react'; | |||||
import React, { useState } from 'react'; | |||||
import { useIntl } from 'react-intl'; | import { useIntl } from 'react-intl'; | ||||
import { CallBackProps } from 'react-joyride'; | import { CallBackProps } from 'react-joyride'; | ||||
import { createSharedStoreHook } from 'shared-store-hook'; | |||||
import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; | import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; | ||||
import DocumentationLink from '../../../components/common/DocumentationLink'; | import DocumentationLink from '../../../components/common/DocumentationLink'; | ||||
import { SCREEN_POSITION_COMPUTE_DELAY } from '../../../components/common/ScreenPositionHelper'; | import { SCREEN_POSITION_COMPUTE_DELAY } from '../../../components/common/ScreenPositionHelper'; | ||||
import { Issue } from '../../../types/types'; | import { Issue } from '../../../types/types'; | ||||
import { NoticeType } from '../../../types/users'; | import { NoticeType } from '../../../types/users'; | ||||
export const useAcceptGuideState = createSharedStoreHook<{ | |||||
stepIndex: number; | |||||
guideIsRunning: boolean; | |||||
}>({ | |||||
initialState: { stepIndex: 0, guideIsRunning: false }, | |||||
}); | |||||
interface Props { | interface Props { | ||||
run?: boolean; | run?: boolean; | ||||
togglePopup: (issue: string, popup: string, show?: boolean) => void; | togglePopup: (issue: string, popup: string, show?: boolean) => void; | ||||
const PLACEMENT_RIGHT = 'right'; | const PLACEMENT_RIGHT = 'right'; | ||||
const DOC_LINK = '/user-guide/issues/#statuses'; | const DOC_LINK = '/user-guide/issues/#statuses'; | ||||
export const SESSION_STORAGE_TRANSITION_GUIDE_KEY = 'issueNewStatusAndTransitionGuideStep'; | |||||
const EXTRA_DELAY = 100; | const EXTRA_DELAY = 100; | ||||
const GUIDE_WIDTH = 360; | const GUIDE_WIDTH = 360; | ||||
export default function IssueNewStatusAndTransitionGuide(props: Readonly<Props>) { | export default function IssueNewStatusAndTransitionGuide(props: Readonly<Props>) { | ||||
const { run, issues } = props; | |||||
const { run, issues, togglePopup } = props; | |||||
const { currentUser, updateDismissedNotices } = useCurrentUser(); | const { currentUser, updateDismissedNotices } = useCurrentUser(); | ||||
const { mutateAsync: dismissNotice } = useDismissNoticeMutation(); | const { mutateAsync: dismissNotice } = useDismissNoticeMutation(); | ||||
const intl = useIntl(); | 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) => | const issueWithAcceptTransition = issues.find((issue) => | ||||
issue.transitions.includes(IssueTransition.Accept), | issue.transitions.includes(IssueTransition.Accept), | ||||
// to ensure proper positioning of the SpotlightTour in the context of ScreenPositionHelper, | // to ensure proper positioning of the SpotlightTour in the context of ScreenPositionHelper, | ||||
// then start the tour. | // then start the tour. | ||||
React.useEffect(() => { | 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(() => { | setTimeout(() => { | ||||
// Scroll to issue. This ensures proper rendering of the SpotlightTour. | // Scroll to issue. This ensures proper rendering of the SpotlightTour. | ||||
document | document | ||||
.querySelector(`[data-guiding-id="issue-transition-${issueWithAcceptTransition.key}"]`) | .querySelector(`[data-guiding-id="issue-transition-${issueWithAcceptTransition.key}"]`) | ||||
?.scrollIntoView({ behavior: 'instant', block: 'center' }); | ?.scrollIntoView({ behavior: 'instant', block: 'center' }); | ||||
// Start the tour | // 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); | }, 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(() => { | 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; | 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; | |||||
} | } | ||||
}; | }; | ||||
return ( | return ( | ||||
<SpotlightTour | <SpotlightTour | ||||
width={GUIDE_WIDTH} | width={GUIDE_WIDTH} | ||||
callback={handleTourCallback} | |||||
callback={onToggle} | |||||
steps={steps} | steps={steps} | ||||
stepIndex={stepIndex} | |||||
run={guideIsRunning} | |||||
stepIndex={step} | |||||
run={run} | |||||
continuous | continuous | ||||
skipLabel={intl.formatMessage({ id: 'skip' })} | skipLabel={intl.formatMessage({ id: 'skip' })} | ||||
backLabel={intl.formatMessage({ id: 'go_back' })} | backLabel={intl.formatMessage({ id: 'go_back' })} |
import { act } from '@testing-library/react'; | import { act } from '@testing-library/react'; | ||||
import userEvent from '@testing-library/user-event'; | import userEvent from '@testing-library/user-event'; | ||||
import React from 'react'; | 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(() => { | beforeEach(() => { | ||||
usersHandler.reset(); | |||||
issuesHandler.reset(); | issuesHandler.reset(); | ||||
}); | }); | ||||
} from 'design-system'; | } from 'design-system'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { addIssueComment, setIssueTransition } from '../../../api/issues'; | 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 { translate, translateWithParameters } from '../../../helpers/l10n'; | ||||
import { Issue } from '../../../types/types'; | import { Issue } from '../../../types/types'; | ||||
import StatusHelper from '../../shared/StatusHelper'; | import StatusHelper from '../../shared/StatusHelper'; | ||||
export default function IssueTransition(props: Readonly<Props>) { | export default function IssueTransition(props: Readonly<Props>) { | ||||
const { isOpen, issue, onChange, togglePopup } = 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); | const [transitioning, setTransitioning] = React.useState(false); | ||||
async function handleSetTransition(transition: string, comment?: string) { | async function handleSetTransition(transition: string, comment?: string) { |