feat: new user course home tour (AA-1027) (#750)
This commit is contained in:
32
docs/decisions/0010-tour-structure-decisions.md
Normal file
32
docs/decisions/0010-tour-structure-decisions.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Tour Structure Decisions
|
||||
|
||||
## Compartmentalizing the tour objects
|
||||
We created the directory `src/tours` in order to organize tours across the MFE. Each tour has its own JSX file where we
|
||||
define a tour object to be passed to the `<Tour />` component in `<LoadedTabPage />`.
|
||||
|
||||
Although each tour is stored in a JSX file, the tour object itself is meant to be an `object` type. Thus, the structure
|
||||
of each tour object is as follows:
|
||||
```$xslt
|
||||
// Note: this is a simplified version of a tour object
|
||||
|
||||
const exampleTour = (enabled) => ({
|
||||
checkpoints: [],
|
||||
enabled,
|
||||
tourId: 'exampleTour',
|
||||
});
|
||||
```
|
||||
|
||||
The reason we use a JSX file rather than a JS file is to allow for use of React components within the objects such as
|
||||
`<FormattedMessage />`.
|
||||
|
||||
## Implementing i18n in tour objects
|
||||
The `<Tour />` component ingests a single prop called `tours` which expects a list of objects.
|
||||
Given the structure in which we organized tour objects, there were two considerations in working with i18n:
|
||||
- You can't injectIntl into something that isn't a React component without considerable adjustments,
|
||||
so using the familiar `{intl.formatMessage(messages.foo)}` syntax would not be possible.
|
||||
- You can't return normal objects from a React component, only React elements. I.e. switching these from arrow functions
|
||||
to React function based components would not be ideal because the `tours` prop expects objects.
|
||||
|
||||
### Decision
|
||||
We chose to use `<FormattedMessage />` directly within the tour objects. We also created shared `<FormattedMessage />`
|
||||
components inside of `GenericTourFormattedMessages.jsx` for use across the tours.
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -4149,9 +4149,9 @@
|
||||
}
|
||||
},
|
||||
"@edx/paragon": {
|
||||
"version": "16.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-16.18.0.tgz",
|
||||
"integrity": "sha512-VxNYFymoGQx0SG0wuotYPbco98Qk2Ao6gZUFyRCb49QIK0kjmnhdV04ltWfG2X7RNjziuVKsak43NbI53Zo5TQ==",
|
||||
"version": "16.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-16.19.0.tgz",
|
||||
"integrity": "sha512-1URyxUC42JT+DF7hWY75SrY4q/zU9dDB8noiIsSVuB2LCoYze209PATmp5L7DBRj6d4m3b859SPqvPD9v2P65Q==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@edx/frontend-enterprise-utils": "1.1.1",
|
||||
"@edx/frontend-lib-special-exams": "1.14.1",
|
||||
"@edx/frontend-platform": "1.14.3",
|
||||
"@edx/paragon": "16.18.0",
|
||||
"@edx/paragon": "16.19.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
|
||||
@@ -10,7 +10,7 @@ function CourseTabsNavigation({
|
||||
activeTabSlug, className, tabs, intl,
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames('course-tabs-navigation', className)}>
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-xl">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import AnonymousUserMenu from './AnonymousUserMenu';
|
||||
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
|
||||
import LaunchCourseHomeTourButton from '../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function LinkedLogo({
|
||||
@@ -29,7 +31,7 @@ LinkedLogo.propTypes = {
|
||||
};
|
||||
|
||||
function Header({
|
||||
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
|
||||
courseOrg, courseNumber, courseTitle, intl, metadataModel, showLaunchTourButton, showUserDropdown,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
@@ -60,6 +62,7 @@ function Header({
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
{showLaunchTourButton && (<LaunchCourseHomeTourButton metadataModel={metadataModel} srOnly />)}
|
||||
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
|
||||
<div className="container-xl py-2 d-flex align-items-center">
|
||||
{headerLogo}
|
||||
@@ -86,6 +89,8 @@ Header.propTypes = {
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
metadataModel: PropTypes.string,
|
||||
showLaunchTourButton: PropTypes.bool,
|
||||
showUserDropdown: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -93,6 +98,8 @@ Header.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
metadataModel: 'courseHomeMeta',
|
||||
showLaunchTourButton: false,
|
||||
showUserDropdown: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ Factory.define('courseHomeMetadata')
|
||||
},
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
user_timezone: 'UTC',
|
||||
username: 'testuser',
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ Object {
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "testuser",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
@@ -301,6 +302,13 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -375,6 +383,7 @@ Object {
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "testuser",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
@@ -485,6 +494,13 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -559,6 +575,7 @@ Object {
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "testuser",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
@@ -674,5 +691,12 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -185,7 +185,7 @@ function OutlineTab({ intl }) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="list-unstyled">
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
@@ -32,7 +35,7 @@ describe('Outline Tab', () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=testuser`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
@@ -1322,7 +1325,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accont Activation Alert', () => {
|
||||
describe('Account Activation Alert', () => {
|
||||
beforeEach(() => {
|
||||
const intersectionObserverMock = () => ({
|
||||
observe: () => null,
|
||||
@@ -1350,7 +1353,7 @@ describe('Outline Tab', () => {
|
||||
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends account activation email on clicking the resened email in account activation alert', async () => {
|
||||
it('sends account activation email on clicking the re-send email in account activation alert', async () => {
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn().mockImplementation(() => 'true');
|
||||
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
|
||||
|
||||
@@ -29,21 +29,23 @@ function CourseDates({
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
|
||||
<ol className="list-unstyled">
|
||||
{courseDateBlocks.map((courseDateBlock) => (
|
||||
<DateSummary
|
||||
key={courseDateBlock.title + courseDateBlock.date}
|
||||
dateBlock={courseDateBlock}
|
||||
userTimezone={userTimezone}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
<div id="courseHome-dates">
|
||||
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
|
||||
<ol className="list-unstyled">
|
||||
{courseDateBlocks.map((courseDateBlock) => (
|
||||
<DateSummary
|
||||
key={courseDateBlock.title + courseDateBlock.date}
|
||||
dateBlock={courseDateBlock}
|
||||
userTimezone={userTimezone}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
|
||||
|
||||
function CourseTools({ courseId, intl }) {
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
@@ -69,6 +70,9 @@ function CourseTools({ courseId, intl }) {
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li className="small" id="courseHome-launchTourLink">
|
||||
<LaunchCourseHomeTourButton />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ function WeeklyLearningGoalCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
id="courseHome-weeklyLearningGoal"
|
||||
className="row w-100 m-0 mb-3 shadow-sm border-0"
|
||||
data-testid="weekly-learning-goal-card"
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ function SequenceNavigation({
|
||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
<nav className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTrigger ? '90%' : null }}>
|
||||
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTrigger ? '90%' : null }}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
||||
{shouldDisplayNotificationTrigger ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
|
||||
@@ -375,22 +375,24 @@ function UpgradeNotification({
|
||||
|
||||
return (
|
||||
<section className={classNames('upgrade-notification small', { 'card mb-4': shouldDisplayBorder })}>
|
||||
<h2 className="h5 upgrade-notification-header" id="outline-sidebar-upgrade-header">
|
||||
{upgradeNotificationHeaderText}
|
||||
</h2>
|
||||
{expirationBanner}
|
||||
<div className="upgrade-notification-message">
|
||||
{upsellMessage}
|
||||
<div id="courseHome-upgradeNotification">
|
||||
<h2 className="h5 upgrade-notification-header" id="outline-sidebar-upgrade-header">
|
||||
{upgradeNotificationHeaderText}
|
||||
</h2>
|
||||
{expirationBanner}
|
||||
<div className="upgrade-notification-message">
|
||||
{upsellMessage}
|
||||
</div>
|
||||
<div className="upgrade-notification-button">
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
{offerCode}
|
||||
</div>
|
||||
<div className="upgrade-notification-button">
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
{offerCode}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,6 +396,7 @@
|
||||
@import "course-home/progress-tab/course-completion/CompletionDonutChart.scss";
|
||||
@import "course-home/progress-tab/grades/course-grade/GradeBar.scss";
|
||||
@import "courseware/course/course-exit/CourseRecommendations";
|
||||
@import "src/tour/Checkpoint.scss";
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
@import "experiments/mm-p2p/index.scss";
|
||||
|
||||
22
src/product-tours/AbandonTour.jsx
Normal file
22
src/product-tours/AbandonTour.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { OkayButtonFormattedMessage } from './GenericTourFormattedMessages';
|
||||
|
||||
const abandonTour = ({ enabled, onEnd }) => ({
|
||||
checkpoints: [{
|
||||
body: <FormattedMessage
|
||||
id="tours.abandonTour.launchTourCheckpoint.body"
|
||||
defaultMessage="Feeling lost? Launch the tour any time for some quick tips to get the most out of the experience."
|
||||
/>,
|
||||
placement: 'left',
|
||||
target: '#courseHome-launchTourLink',
|
||||
}],
|
||||
enabled,
|
||||
endButtonText: <OkayButtonFormattedMessage />,
|
||||
onEnd,
|
||||
onEscape: onEnd,
|
||||
tourId: 'abandonTour',
|
||||
});
|
||||
|
||||
export default abandonTour;
|
||||
22
src/product-tours/CoursewareTour.jsx
Normal file
22
src/product-tours/CoursewareTour.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { OkayButtonFormattedMessage } from './GenericTourFormattedMessages';
|
||||
|
||||
const coursewareTour = ({ enabled, onEnd }) => ({
|
||||
checkpoints: [{
|
||||
body: <FormattedMessage
|
||||
id="tours.sequenceNavigationCheckpoint.body"
|
||||
defaultMessage="The top bar within your course allows you to easily jump to different sections and shows you what’s coming up."
|
||||
/>,
|
||||
placement: 'bottom',
|
||||
target: '#courseware-sequenceNavigation',
|
||||
}],
|
||||
enabled,
|
||||
endButtonText: <OkayButtonFormattedMessage />,
|
||||
onEnd,
|
||||
onEscape: onEnd,
|
||||
tourId: 'coursewareTour',
|
||||
});
|
||||
|
||||
export default coursewareTour;
|
||||
22
src/product-tours/ExistingUserCourseHomeTour.jsx
Normal file
22
src/product-tours/ExistingUserCourseHomeTour.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { OkayButtonFormattedMessage } from './GenericTourFormattedMessages';
|
||||
|
||||
const existingUserCourseHomeTour = ({ enabled, onEnd }) => ({
|
||||
checkpoints: [{
|
||||
body: <FormattedMessage
|
||||
id="tours.existingUserTour.launchTourCheckpoint.body"
|
||||
defaultMessage="We’ve recently added a few new features to the course experience. Want some help looking around? Take a tour to learn more."
|
||||
/>,
|
||||
placement: 'left',
|
||||
target: '#courseHome-launchTourLink',
|
||||
}],
|
||||
enabled,
|
||||
endButtonText: <OkayButtonFormattedMessage />,
|
||||
onEnd,
|
||||
onEscape: onEnd,
|
||||
tourId: 'existingUserCourseHomeTour',
|
||||
});
|
||||
|
||||
export default existingUserCourseHomeTour;
|
||||
32
src/product-tours/GenericTourFormattedMessages.jsx
Normal file
32
src/product-tours/GenericTourFormattedMessages.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
export function DismissButtonFormattedMessage() {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="tours.button.dismiss"
|
||||
defaultMessage="Dismiss"
|
||||
description="A button used to close the tour of the website"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NextButtonFormattedMessage() {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="tours.button.next"
|
||||
defaultMessage="Next"
|
||||
description="A button used within a tour of the website to advance to the next piece of information"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OkayButtonFormattedMessage() {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="tours.button.okay"
|
||||
defaultMessage="Okay"
|
||||
description="A button used to end the tour of the website"
|
||||
/>
|
||||
);
|
||||
}
|
||||
184
src/product-tours/ProductTours.jsx
Normal file
184
src/product-tours/ProductTours.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import Tour from '../tour/Tour';
|
||||
|
||||
import { useModel } from '../generic/model-store';
|
||||
|
||||
import abandonTour from './AbandonTour';
|
||||
import coursewareTour from './CoursewareTour';
|
||||
import existingUserCourseHomeTour from './ExistingUserCourseHomeTour';
|
||||
import newUserCourseHomeTour from './newUserCourseHomeTour/NewUserCourseHomeTour';
|
||||
import NewUserCourseHomeTourModal from './newUserCourseHomeTour/NewUserCourseHomeTourModal';
|
||||
import {
|
||||
closeNewUserCourseHomeModal,
|
||||
endCourseHomeTour,
|
||||
endCoursewareTour,
|
||||
fetchTourData,
|
||||
} from './data/thunks';
|
||||
|
||||
function ProductTours({
|
||||
activeTab,
|
||||
courseId,
|
||||
isStreakCelebrationOpen,
|
||||
metadataModel,
|
||||
org,
|
||||
}) {
|
||||
if (isStreakCelebrationOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
username,
|
||||
verifiedMode,
|
||||
} = useModel(metadataModel, courseId);
|
||||
|
||||
const {
|
||||
showCoursewareTour,
|
||||
showExistingUserCourseHomeTour,
|
||||
showNewUserCourseHomeModal,
|
||||
showNewUserCourseHomeTour,
|
||||
} = useSelector(state => state.tours);
|
||||
|
||||
const [isAbandonTourEnabled, setIsAbandonTourEnabled] = useState(false);
|
||||
const [isCoursewareTourEnabled, setIsCoursewareTourEnabled] = useState(false);
|
||||
const [isExistingUserCourseHomeTourEnabled, setIsExistingUserCourseHomeTourEnabled] = useState(false);
|
||||
const [isNewUserCourseHomeTourEnabled, setIsNewUserCourseHomeTourEnabled] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const administrator = getAuthenticatedUser() && getAuthenticatedUser().administrator;
|
||||
|
||||
useEffect(() => {
|
||||
// Tours currently only exist on the Outline Tab and within Courseware, so we'll avoid
|
||||
// calling the tour endpoint unnecessarily.
|
||||
if (username && (activeTab === 'outline' || metadataModel === 'coursewareMeta')) {
|
||||
dispatch(fetchTourData(username));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataModel === 'coursewareMeta' && showCoursewareTour) {
|
||||
setIsCoursewareTourEnabled(true);
|
||||
}
|
||||
}, [showCoursewareTour]);
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataModel === 'courseHomeMeta') {
|
||||
setIsExistingUserCourseHomeTourEnabled(!!showExistingUserCourseHomeTour);
|
||||
}
|
||||
}, [showExistingUserCourseHomeTour]);
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataModel === 'courseHomeMeta' && showNewUserCourseHomeTour) {
|
||||
setIsAbandonTourEnabled(false);
|
||||
setIsNewUserCourseHomeTourEnabled(true);
|
||||
}
|
||||
}, [showNewUserCourseHomeTour]);
|
||||
|
||||
const upgradeData = {
|
||||
courseId,
|
||||
org,
|
||||
upgradeUrl: verifiedMode && verifiedMode.upgradeUrl,
|
||||
};
|
||||
|
||||
// The <Tour /> component cannot handle rendering multiple enabled tours at once.
|
||||
// I.e. when adding new tours, beware that if multiple tours are enabled,
|
||||
// the first enabled tour in the following array will be the only one that renders.
|
||||
// The suggestion for populating these tour objects is to ensure only one tour is enabled at a time.
|
||||
const tours = [
|
||||
abandonTour({
|
||||
enabled: isAbandonTourEnabled,
|
||||
onEnd: () => setIsAbandonTourEnabled(false),
|
||||
}),
|
||||
coursewareTour({
|
||||
enabled: isCoursewareTourEnabled,
|
||||
onEnd: () => {
|
||||
setIsCoursewareTourEnabled(false);
|
||||
sendTrackEvent('edx.ui.lms.courseware_tour.completed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(endCoursewareTour(username));
|
||||
},
|
||||
}),
|
||||
existingUserCourseHomeTour({
|
||||
enabled: isExistingUserCourseHomeTourEnabled,
|
||||
onEnd: () => {
|
||||
setIsExistingUserCourseHomeTourEnabled(false);
|
||||
sendTrackEvent('edx.ui.lms.existing_user_tour.completed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(endCourseHomeTour(username));
|
||||
},
|
||||
}),
|
||||
newUserCourseHomeTour({
|
||||
enabled: isNewUserCourseHomeTourEnabled,
|
||||
onDismiss: () => {
|
||||
setIsNewUserCourseHomeTourEnabled(false);
|
||||
setIsAbandonTourEnabled(true);
|
||||
sendTrackEvent('edx.ui.lms.new_user_tour.dismissed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(endCourseHomeTour(username));
|
||||
},
|
||||
onEnd: () => {
|
||||
setIsNewUserCourseHomeTourEnabled(false);
|
||||
sendTrackEvent('edx.ui.lms.new_user_tour.completed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(endCourseHomeTour(username));
|
||||
},
|
||||
upgradeData,
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tour
|
||||
tours={tours}
|
||||
/>
|
||||
<NewUserCourseHomeTourModal
|
||||
isOpen={metadataModel === 'courseHomeMeta' && showNewUserCourseHomeModal}
|
||||
onDismiss={() => {
|
||||
sendTrackEvent('edx.ui.lms.new_user_modal.dismissed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(closeNewUserCourseHomeModal());
|
||||
setIsAbandonTourEnabled(true);
|
||||
dispatch(endCourseHomeTour(username));
|
||||
}}
|
||||
onStartTour={() => {
|
||||
sendTrackEvent('edx.ui.lms.new_user_tour.started', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(closeNewUserCourseHomeModal());
|
||||
setIsNewUserCourseHomeTourEnabled(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProductTours.propTypes = {
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
isStreakCelebrationOpen: PropTypes.bool.isRequired,
|
||||
metadataModel: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ProductTours;
|
||||
308
src/product-tours/ProductTours.test.jsx
Normal file
308
src/product-tours/ProductTours.test.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { waitForElementToBeRemoved } from '@testing-library/dom';
|
||||
import * as popper from '@popperjs/core';
|
||||
|
||||
import {
|
||||
fireEvent, initializeMockApp, logUnhandledRequests, render, screen,
|
||||
} from '../setupTest';
|
||||
import initializeStore from '../store';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../utils';
|
||||
|
||||
import CoursewareContainer from '../courseware/CoursewareContainer';
|
||||
import LoadedTabPage from '../tab-page/LoadedTabPage';
|
||||
import OutlineTab from '../course-home/outline-tab/OutlineTab';
|
||||
import * as courseHomeThunks from '../course-home/data/thunks';
|
||||
import { buildSimpleCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
||||
|
||||
import { UserMessagesProvider } from '../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
const popperMock = jest.spyOn(popper, 'createPopper');
|
||||
|
||||
describe('Course Home Tours', () => {
|
||||
let axiosMock;
|
||||
|
||||
const courseId = 'course-v1:edX+Test+run';
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const tourDataUrl = `${getConfig().LMS_BASE_URL}/api/user_tours/v1/testuser`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('outlineTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
function setTourData(tourData, response = 200, isEnrolled = true) {
|
||||
setMetadata({ is_enrolled: isEnrolled });
|
||||
axiosMock.onGet(tourDataUrl).reply(response, tourData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(courseHomeThunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
render(
|
||||
<LoadedTabPage courseId={courseId} activeTabSlug="outline">
|
||||
<OutlineTab />
|
||||
</LoadedTabPage>,
|
||||
{ store },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
popperMock.mockImplementation(jest.fn());
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
// Set defaults for network requests
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(tourDataUrl).reply(200, {
|
||||
course_home_tour_status: 'no-tour',
|
||||
show_courseware_tour: false,
|
||||
});
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
popperMock.mockReset();
|
||||
});
|
||||
|
||||
describe('for new users', () => {
|
||||
beforeEach(async () => {
|
||||
setTourData({
|
||||
course_home_tour_status: 'show-new-user-tour',
|
||||
show_courseware_tour: false,
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('renders modal', async () => {
|
||||
expect(await screen.findByRole('dialog', { name: 'New user course home prompt' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Begin tour' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders checkpoint on click of "Begin tour"', async () => {
|
||||
const beginTourButton = await screen.findByRole('button', { name: 'Begin tour' });
|
||||
fireEvent.click(beginTourButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'Take the course!' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('for eligible existing users', () => {
|
||||
it('renders correctly', async () => {
|
||||
setTourData({
|
||||
course_home_tour_status: 'show-existing-user-tour',
|
||||
show_courseware_tour: false,
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('We’ve recently added a few new features to the course experience.', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for non-eligible existing users', () => {
|
||||
beforeEach(async () => {
|
||||
setTourData({
|
||||
course_home_tour_status: 'no-tour',
|
||||
show_courseware_tour: false,
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('does not render a tour', async () => {
|
||||
expect(await screen.queryByRole('dialog', { name: 'Take the course!' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('launches tour on button click', async () => {
|
||||
const launchTourButton = await screen.findByRole('button', { name: 'Launch tour' });
|
||||
expect(launchTourButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(launchTourButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'Take the course!' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
errorStatus
|
||||
${401}
|
||||
${403}
|
||||
${404}
|
||||
`('does not render tour components for $errorStatus response', async (errorStatus) => {
|
||||
setTourData({}, errorStatus, false);
|
||||
|
||||
// Verify no launch tour button
|
||||
expect(await screen.queryByRole('button', { name: 'Launch tour' })).not.toBeInTheDocument();
|
||||
|
||||
// Verify no Checkpoint or MarketingModal has rendered
|
||||
expect(await screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function MockUnit({ courseId, id }) { // eslint-disable-line react/prop-types
|
||||
return (
|
||||
<div id="courseware-sequenceNavigation" className="fake-unit">Unit Contents {courseId} {id}</div>
|
||||
);
|
||||
}
|
||||
|
||||
jest.mock(
|
||||
'../courseware/course/sequence/Unit',
|
||||
() => MockUnit,
|
||||
);
|
||||
|
||||
describe('Courseware Tour', () => {
|
||||
let store;
|
||||
let component;
|
||||
let axiosMock;
|
||||
|
||||
// This is a standard set of data that can be used in CoursewareContainer tests.
|
||||
// By default, `setUpMockRequests()` will configure the mock LMS API to return use this data.
|
||||
// Certain test cases override these in order to test with special blocks/metadata.
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const courseId = courseMetadata.id;
|
||||
const unitBlocks = [
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId },
|
||||
),
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId },
|
||||
),
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId },
|
||||
),
|
||||
];
|
||||
const {
|
||||
courseBlocks,
|
||||
sequenceBlocks: [defaultSequenceBlock],
|
||||
} = buildSimpleCourseBlocks(
|
||||
courseId,
|
||||
courseMetadata.name,
|
||||
{ unitBlocks },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
popperMock.mockImplementation(jest.fn());
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
store = initializeStore();
|
||||
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
</Switch>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
async function loadContainer() {
|
||||
const { container } = render(component);
|
||||
// Wait for the page spinner to be removed, such that we can wait for our main
|
||||
// content to load before making any assertions.
|
||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('when receiving successful course data', () => {
|
||||
const tourDataUrl = `${getConfig().LMS_BASE_URL}/api/user_tours/v1/testuser`;
|
||||
|
||||
beforeEach(async () => {
|
||||
// On page load, SequenceContext attempts to scroll to the top of the page.
|
||||
global.scrollTo = jest.fn();
|
||||
|
||||
// If we weren't given a list of sequence metadatas for URL mocking,
|
||||
// then construct it ourselves by looking at courseBlocks.
|
||||
const sequenceMetadatas = (
|
||||
Object.values(courseBlocks.blocks)
|
||||
.filter(block => block.type === 'sequential')
|
||||
.map(sequenceBlock => Factory.build(
|
||||
'sequenceMetadata',
|
||||
{},
|
||||
{
|
||||
courseId,
|
||||
sequenceBlock,
|
||||
unitBlocks: sequenceBlock.children.map(unitId => courseBlocks.blocks[unitId]),
|
||||
},
|
||||
))
|
||||
);
|
||||
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
const courseMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
|
||||
sequenceMetadatas.forEach(sequenceMetadata => {
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
|
||||
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
|
||||
const proctoredExamApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${courseId}/content_id/${sequenceMetadata.item_id}?is_learning_mfe=true`;
|
||||
axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} });
|
||||
});
|
||||
|
||||
axiosMock.onPost(`${courseId}/xblock/${defaultSequenceBlock.id}/handler/get_completion`).reply(200, {
|
||||
complete: true,
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}/${defaultSequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
});
|
||||
|
||||
it.each`
|
||||
showCoursewareTour
|
||||
${true}
|
||||
${false}
|
||||
`('should load courseware checkpoint correctly if tour enabled is $showCoursewareTour', async (showCoursewareTour) => {
|
||||
axiosMock.onGet(tourDataUrl).reply(200, {
|
||||
course_home_tour_status: 'no-tour',
|
||||
show_courseware_tour: showCoursewareTour,
|
||||
});
|
||||
|
||||
const container = await loadContainer();
|
||||
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
const sequenceNextButton = sequenceNavButtons[4];
|
||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||
fireEvent.click(sequenceNextButton);
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${defaultSequenceBlock.id}/${unitBlocks[1].id}`);
|
||||
|
||||
const checkpoint = container.querySelectorAll('#checkpoint');
|
||||
expect(checkpoint).toHaveLength(showCoursewareTour ? 1 : 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/product-tours/data/api.js
Normal file
26
src/product-tours/data/api.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export async function getTourData(username) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/user_tours/v1/${username}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return { toursEnabled: true, ...camelCaseObject(data) };
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
/** The API will return a
|
||||
* 401 if the user is not authenticated
|
||||
* 403 if the tour waffle flag is inactive
|
||||
* 404 if no User Tour objects exist for the given username
|
||||
*/
|
||||
if (httpErrorStatus === 401 || httpErrorStatus === 403 || httpErrorStatus === 404) {
|
||||
return { toursEnabled: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchTourData(username, tourData) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/user_tours/v1/${username}`;
|
||||
return getAuthenticatedHttpClient().patch(url, tourData);
|
||||
}
|
||||
8
src/product-tours/data/index.js
Normal file
8
src/product-tours/data/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
closeNewUserCourseHomeModal,
|
||||
endCourseHomeTour,
|
||||
endCoursewareTour,
|
||||
fetchTourData,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
58
src/product-tours/data/slice.js
Normal file
58
src/product-tours/data/slice.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'tours',
|
||||
initialState: {
|
||||
showCoursewareTour: false,
|
||||
showExistingUserCourseHomeTour: false,
|
||||
showNewUserCourseHomeModal: false,
|
||||
showNewUserCourseHomeTour: false,
|
||||
toursEnabled: false,
|
||||
},
|
||||
reducers: {
|
||||
disableCourseHomeTour: (state) => {
|
||||
state.showNewUserCourseHomeModal = false;
|
||||
state.showNewUserCourseHomeTour = false;
|
||||
state.showExistingUserCourseHomeTour = false;
|
||||
},
|
||||
disableCoursewareTour: (state) => {
|
||||
state.showCoursewareTour = false;
|
||||
},
|
||||
disableNewUserCourseHomeModal: (state) => {
|
||||
state.showNewUserCourseHomeModal = false;
|
||||
},
|
||||
launchCourseHomeTour: (state) => {
|
||||
if (state.showExistingUserCourseHomeTour) {
|
||||
state.showExistingUserCourseHomeTour = false;
|
||||
}
|
||||
|
||||
if (!state.showNewUserCourseHomeModal || !state.showNewUserCourseHomeTour) {
|
||||
state.showNewUserCourseHomeTour = true;
|
||||
}
|
||||
},
|
||||
setTourData: (state, { payload }) => {
|
||||
const {
|
||||
courseHomeTourStatus,
|
||||
showCoursewareTour,
|
||||
toursEnabled,
|
||||
} = payload;
|
||||
state.showCoursewareTour = showCoursewareTour;
|
||||
state.showExistingUserCourseHomeTour = courseHomeTourStatus === 'show-existing-user-tour';
|
||||
state.showNewUserCourseHomeModal = courseHomeTourStatus === 'show-new-user-tour';
|
||||
state.toursEnabled = toursEnabled;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
disableCourseHomeTour,
|
||||
disableCoursewareTour,
|
||||
disableNewUserCourseHomeModal,
|
||||
launchCourseHomeTour,
|
||||
setTourData,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
50
src/product-tours/data/thunks.js
Normal file
50
src/product-tours/data/thunks.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getTourData, patchTourData } from './api';
|
||||
import {
|
||||
disableCourseHomeTour,
|
||||
disableCoursewareTour,
|
||||
disableNewUserCourseHomeModal,
|
||||
setTourData,
|
||||
} from './slice';
|
||||
|
||||
export function closeNewUserCourseHomeModal() {
|
||||
return async (dispatch) => dispatch(disableNewUserCourseHomeModal());
|
||||
}
|
||||
|
||||
export function endCourseHomeTour(username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
await patchTourData(username, {
|
||||
course_home_tour_status: 'no-tour',
|
||||
});
|
||||
dispatch(disableCourseHomeTour());
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function endCoursewareTour(username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
await patchTourData(username, {
|
||||
show_courseware_tour: false,
|
||||
});
|
||||
dispatch(disableCoursewareTour());
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchTourData(username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await getTourData(username);
|
||||
dispatch(setTourData(data));
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
30
src/product-tours/messages.js
Normal file
30
src/product-tours/messages.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
beginTour: {
|
||||
id: 'tours.button.beginTour',
|
||||
defaultMessage: 'Begin tour',
|
||||
description: 'A button used to start a tour of the website',
|
||||
},
|
||||
launchTour: {
|
||||
id: 'tours.button.launchTour',
|
||||
defaultMessage: 'Launch tour',
|
||||
description: 'A button used to launch a tour of the website',
|
||||
},
|
||||
newUserModalBody: {
|
||||
id: 'tours.newUserModal.body',
|
||||
defaultMessage: 'Let’s take a quick tour of edX so you can get the most out of your course.',
|
||||
},
|
||||
newUserModalTitleWelcome: {
|
||||
id: 'tours.newUserModal.title.welcome',
|
||||
defaultMessage: 'Welcome to your',
|
||||
description: 'The beginning of the phrase "Welcome to your edX course!"',
|
||||
},
|
||||
skipForNow: {
|
||||
id: 'tours.button.skipForNow',
|
||||
defaultMessage: 'Skip for now',
|
||||
description: 'A button used to dismiss the modal and skip the optional tour of the website',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { Compass } from '@edx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { launchCourseHomeTour } from '../data/slice';
|
||||
import messages from '../messages';
|
||||
|
||||
function LaunchCourseHomeTourButton({ intl, metadataModel, srOnly }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
org,
|
||||
} = useModel(metadataModel, courseId);
|
||||
|
||||
const {
|
||||
toursEnabled,
|
||||
} = useSelector(state => state.tours);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = () => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent('edx.ui.lms.launch_tour.clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
tour_variant: 'course_home',
|
||||
});
|
||||
|
||||
dispatch(launchCourseHomeTour());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{toursEnabled && (
|
||||
<Button variant="link" size="inline" className={`p-0 ${srOnly && 'sr-only sr-only-focusable'}`} onClick={handleClick}>
|
||||
{!srOnly && (
|
||||
<Icon
|
||||
src={Compass}
|
||||
className="mr-2"
|
||||
style={{ height: '18px', width: '18px' }}
|
||||
/>
|
||||
)}
|
||||
{intl.formatMessage(messages.launchTour)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LaunchCourseHomeTourButton.defaultProps = {
|
||||
metadataModel: 'courseHomeMeta',
|
||||
srOnly: false,
|
||||
};
|
||||
|
||||
LaunchCourseHomeTourButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
metadataModel: PropTypes.string,
|
||||
srOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default injectIntl(LaunchCourseHomeTourButton);
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DismissButtonFormattedMessage,
|
||||
NextButtonFormattedMessage,
|
||||
OkayButtonFormattedMessage,
|
||||
} from '../GenericTourFormattedMessages';
|
||||
|
||||
const datesCheckpoint = {
|
||||
body: <FormattedMessage
|
||||
id="tours.datesCheckpoint.body"
|
||||
defaultMessage="Important dates can help you stay on track."
|
||||
/>,
|
||||
placement: 'left-start',
|
||||
target: '#courseHome-dates',
|
||||
title: <FormattedMessage
|
||||
id="tours.datesCheckpoint.title"
|
||||
defaultMessage="Keep on top of key dates"
|
||||
/>,
|
||||
};
|
||||
|
||||
const outlineCheckpoint = {
|
||||
body: <FormattedMessage
|
||||
id="tours.outlineCheckpoint.body"
|
||||
defaultMessage="You can explore sections of the course using the outline below."
|
||||
/>,
|
||||
placement: 'top',
|
||||
target: '#courseHome-outline',
|
||||
title: <FormattedMessage
|
||||
id="tours.outlineCheckpoint.title"
|
||||
defaultMessage="Take the course!"
|
||||
/>,
|
||||
};
|
||||
|
||||
const tabNavigationCheckpoint = {
|
||||
body: <FormattedMessage
|
||||
id="tours.tabNavigationCheckpoint.body"
|
||||
defaultMessage="These tabs can be used to access other course materials, such as your progress, syllabus, etc."
|
||||
/>,
|
||||
placement: 'bottom',
|
||||
target: '#courseTabsNavigation',
|
||||
title: <FormattedMessage
|
||||
id="tours.tabNavigationCheckpoint.title"
|
||||
defaultMessage="Additional course resources"
|
||||
/>,
|
||||
};
|
||||
|
||||
const upgradeCheckpoint = (logUpgradeClick, upgradeLink) => ({
|
||||
body: <FormattedMessage
|
||||
id="tours.upgradeCheckpoint.body"
|
||||
defaultMessage="Work towards a certificate and gain full access to course materials. {upgradeLink}"
|
||||
values={{
|
||||
upgradeLink: (
|
||||
<a href={upgradeLink} onClick={logUpgradeClick}>
|
||||
<FormattedMessage
|
||||
id="tours.upgradeCheckpoint.upgradeLink"
|
||||
defaultMessage="Upgrade now!"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>,
|
||||
placement: 'left-start',
|
||||
target: '#courseHome-upgradeNotification',
|
||||
title: <FormattedMessage
|
||||
id="tours.upgradeCheckpoint.title"
|
||||
defaultMessage="Unlock your course"
|
||||
/>,
|
||||
});
|
||||
|
||||
const weeklyGoalsCheckpoint = {
|
||||
body: <FormattedMessage
|
||||
id="tours.weeklyGoalsCheckpoint.body"
|
||||
defaultMessage="Setting a goal makes you more likely to complete your course."
|
||||
/>,
|
||||
placement: 'left',
|
||||
target: '#courseHome-weeklyLearningGoal',
|
||||
title: <FormattedMessage
|
||||
id="tours.weeklyGoalsCheckpoint.title"
|
||||
defaultMessage="Set a course goal"
|
||||
/>,
|
||||
};
|
||||
|
||||
const newUserCourseHomeTour = ({
|
||||
enabled,
|
||||
onDismiss,
|
||||
onEnd,
|
||||
upgradeData,
|
||||
}) => {
|
||||
const logUpgradeClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: upgradeData.org,
|
||||
courserun_key: upgradeData.courseId,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'course_home_upgrade_product_tour',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
};
|
||||
return ({
|
||||
advanceButtonText: <NextButtonFormattedMessage />,
|
||||
checkpoints: [
|
||||
outlineCheckpoint,
|
||||
datesCheckpoint,
|
||||
tabNavigationCheckpoint,
|
||||
upgradeCheckpoint(logUpgradeClick, upgradeData.upgradeUrl),
|
||||
weeklyGoalsCheckpoint,
|
||||
],
|
||||
dismissButtonText: <DismissButtonFormattedMessage />,
|
||||
enabled,
|
||||
endButtonText: <OkayButtonFormattedMessage />,
|
||||
onDismiss,
|
||||
onEnd,
|
||||
onEscape: onDismiss,
|
||||
tourId: 'newUserCourseHomeTour',
|
||||
});
|
||||
};
|
||||
|
||||
export default newUserCourseHomeTour;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Button, MarketingModal, ModalDialog,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import heroImage from './course_home_tour_modal_hero.png';
|
||||
import messages from '../messages';
|
||||
|
||||
function NewUserCourseHomeTourModal({
|
||||
intl,
|
||||
isOpen,
|
||||
onDismiss,
|
||||
onStartTour,
|
||||
}) {
|
||||
return (
|
||||
<MarketingModal
|
||||
isOpen={isOpen}
|
||||
title="New user course home prompt"
|
||||
heroIsDark
|
||||
hasCloseButton={false}
|
||||
heroNode={(
|
||||
<ModalDialog.Hero>
|
||||
<ModalDialog.Hero.Background
|
||||
backgroundSrc={heroImage}
|
||||
/>
|
||||
<ModalDialog.Hero.Content style={{ maxWidth: '20rem' }}>
|
||||
<ModalDialog.Title as="h2">
|
||||
<FormattedMessage
|
||||
id="tours.newUserModal.title"
|
||||
defaultMessage="{welcome} {siteName} course!"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
welcome: <span className="text-accent-b">{intl.formatMessage(messages.newUserModalTitleWelcome)}</span>,
|
||||
}}
|
||||
/>
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Hero.Content>
|
||||
</ModalDialog.Hero>
|
||||
)}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
{intl.formatMessage(messages.skipForNow)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="brand"
|
||||
onClick={onStartTour}
|
||||
>
|
||||
{intl.formatMessage(messages.beginTour)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
onClose={onDismiss}
|
||||
>
|
||||
<p className="text-dark-900">{intl.formatMessage(messages.newUserModalBody)}</p>
|
||||
</MarketingModal>
|
||||
);
|
||||
}
|
||||
|
||||
NewUserCourseHomeTourModal.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onDismiss: PropTypes.func.isRequired,
|
||||
onStartTour: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NewUserCourseHomeTourModal);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -2,6 +2,7 @@ import { configureStore } from '@reduxjs/toolkit';
|
||||
import { reducer as courseHomeReducer } from './course-home/data';
|
||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
||||
import { reducer as recommendationsReducer } from './courseware/course/course-exit/data/slice';
|
||||
import { reducer as toursReducer } from './product-tours/data';
|
||||
import { reducer as modelsReducer } from './generic/model-store';
|
||||
|
||||
export default function initializeStore() {
|
||||
@@ -11,6 +12,7 @@ export default function initializeStore() {
|
||||
courseware: coursewareReducer,
|
||||
courseHome: courseHomeReducer,
|
||||
recommendations: recommendationsReducer,
|
||||
tours: toursReducer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import InstructorToolbar from '../instructor-toolbar';
|
||||
import useEnrollmentAlert from '../alerts/enrollment-alert';
|
||||
import useLogistrationAlert from '../alerts/logistration-alert';
|
||||
|
||||
import ProductTours from '../product-tours/ProductTours';
|
||||
|
||||
function LoadedTabPage({
|
||||
activeTabSlug,
|
||||
children,
|
||||
@@ -21,11 +23,12 @@ function LoadedTabPage({
|
||||
unitId,
|
||||
}) {
|
||||
const {
|
||||
celebrations,
|
||||
canViewLegacyCourseware,
|
||||
org,
|
||||
originalUserIsStaff,
|
||||
tabs,
|
||||
title,
|
||||
celebrations,
|
||||
canViewLegacyCourseware,
|
||||
verifiedMode,
|
||||
} = useModel(metadataModel, courseId);
|
||||
|
||||
@@ -42,6 +45,13 @@ function LoadedTabPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductTours
|
||||
activeTab={activeTabSlug}
|
||||
courseId={courseId}
|
||||
isStreakCelebrationOpen={isStreakCelebrationOpen}
|
||||
metadataModel={metadataModel}
|
||||
org={org}
|
||||
/>
|
||||
<Helmet>
|
||||
<title>{`${activeTab ? `${activeTab.title} | ` : ''}${title} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
|
||||
@@ -6,6 +6,7 @@ import LoadedTabPage from './LoadedTabPage';
|
||||
jest.mock('../course-header/CourseTabsNavigation', () => () => <div data-testid="CourseTabsNavigation" />);
|
||||
jest.mock('../instructor-toolbar/InstructorToolbar', () => () => <div data-testid="InstructorToolbar" />);
|
||||
jest.mock('../shared/streak-celebration/StreakCelebrationModal', () => () => <div data-testid="StreakModal" />);
|
||||
jest.mock('../product-tours/ProductTours', () => () => <div data-testid="ProductTours" />);
|
||||
|
||||
describe('Loaded Tab Page', () => {
|
||||
const mockData = { activeTabSlug: 'courseware', metadataModel: 'coursewareMeta' };
|
||||
|
||||
@@ -77,6 +77,8 @@ function TabPage({ intl, ...props }) {
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
metadataModel={metadataModel}
|
||||
showLaunchTourLink
|
||||
/>
|
||||
<LoadedTabPage {...props} />
|
||||
<Footer />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createPopper } from '@popperjs/core';
|
||||
|
||||
import CheckpointActionRow from './CheckpointActionRow';
|
||||
import CheckpointBody from './CheckpointBody';
|
||||
@@ -8,12 +10,75 @@ import CheckpointTitle from './CheckpointTitle';
|
||||
|
||||
function Checkpoint({
|
||||
body,
|
||||
hideCheckpoint,
|
||||
index,
|
||||
placement,
|
||||
target,
|
||||
title,
|
||||
totalCheckpoints,
|
||||
...props
|
||||
}) {
|
||||
const [checkpointVisible, setCheckpointVisible] = useState(false);
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 768px)' });
|
||||
|
||||
useEffect(() => {
|
||||
const targetElement = document.querySelector(target);
|
||||
const checkpoint = document.querySelector('#checkpoint');
|
||||
if (targetElement && checkpoint) {
|
||||
// Translate the Checkpoint to its target's coordinates
|
||||
const checkpointPopper = createPopper(targetElement, checkpoint, {
|
||||
placement: isMobile ? 'top' : placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'arrow',
|
||||
options: {
|
||||
padding: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 20],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
padding: 20,
|
||||
tetherOffset: 35,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
setCheckpointVisible(true);
|
||||
if (checkpointPopper) {
|
||||
checkpointPopper.forceUpdate();
|
||||
}
|
||||
}
|
||||
}, [target, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkpointVisible) {
|
||||
const targetElement = document.querySelector(target);
|
||||
let targetOffset = targetElement.getBoundingClientRect().top;
|
||||
if ((targetOffset < 0) || (targetElement.getBoundingClientRect().bottom > window.innerHeight)) {
|
||||
if (placement.includes('top')) {
|
||||
if (targetOffset < 0) {
|
||||
targetOffset *= -1;
|
||||
}
|
||||
targetOffset -= 280;
|
||||
} else {
|
||||
targetOffset -= 80;
|
||||
}
|
||||
|
||||
window.scrollTo({
|
||||
top: targetOffset, behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
const button = document.querySelector('#checkpoint-primary-button');
|
||||
button.focus();
|
||||
}
|
||||
}, [target, checkpointVisible]);
|
||||
const isLastCheckpoint = index + 1 === totalCheckpoints;
|
||||
const isOnlyCheckpoint = totalCheckpoints === 1;
|
||||
return (
|
||||
@@ -22,7 +87,7 @@ function Checkpoint({
|
||||
className="checkpoint-popover p-4 bg-light-300"
|
||||
aria-labelledby="checkpoint-title"
|
||||
role="dialog"
|
||||
style={{ display: hideCheckpoint ? 'none' : 'block' }}
|
||||
style={{ visibility: checkpointVisible ? 'visible' : 'hidden', pointerEvents: checkpointVisible ? 'auto' : 'none' }}
|
||||
>
|
||||
{/* This text is not translated due to Paragon's lack of i18n support */}
|
||||
<span className="sr-only">Top of step {index + 1}</span>
|
||||
@@ -49,20 +114,25 @@ Checkpoint.defaultProps = {
|
||||
body: null,
|
||||
dismissButtonText: null,
|
||||
endButtonText: null,
|
||||
placement: 'top',
|
||||
title: null,
|
||||
};
|
||||
|
||||
Checkpoint.propTypes = {
|
||||
advanceButtonText: PropTypes.string,
|
||||
body: PropTypes.string,
|
||||
dismissButtonText: PropTypes.string,
|
||||
endButtonText: PropTypes.string,
|
||||
hideCheckpoint: PropTypes.bool.isRequired,
|
||||
advanceButtonText: PropTypes.node,
|
||||
body: PropTypes.node,
|
||||
dismissButtonText: PropTypes.node,
|
||||
endButtonText: PropTypes.node,
|
||||
index: PropTypes.number.isRequired,
|
||||
onAdvance: PropTypes.func.isRequired,
|
||||
onDismiss: PropTypes.func.isRequired,
|
||||
onEnd: PropTypes.func.isRequired,
|
||||
title: PropTypes.string,
|
||||
placement: PropTypes.oneOf([
|
||||
'top', 'top-start', 'top-end', 'right-start', 'right', 'right-end',
|
||||
'left-start', 'left', 'left-end', 'bottom', 'bottom-start', 'bottom-end',
|
||||
]),
|
||||
target: PropTypes.string.isRequired,
|
||||
title: PropTypes.node,
|
||||
totalCheckpoints: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
$checkpoint-arrow-width: 15px;
|
||||
$checkpoint-arrow-brand: solid $checkpoint-arrow-width $brand;
|
||||
$checkpoint-arrow-light-300: solid $checkpoint-arrow-width $light-300;
|
||||
$checkpoint-arrow-transparent: solid $checkpoint-arrow-width transparent;
|
||||
|
||||
.checkpoint-popover {
|
||||
position: absolute;
|
||||
border-top: 8px solid $brand;
|
||||
border-radius: $border-radius;
|
||||
box-shadow: $popover-box-shadow;
|
||||
@@ -10,21 +16,21 @@
|
||||
}
|
||||
|
||||
#checkpoint-arrow,
|
||||
#checkpoint-arrow::before {
|
||||
#checkpoint-arrow::before,
|
||||
#checkpoint-arrow::after {
|
||||
position: absolute;
|
||||
width: .65em;
|
||||
height: .65em;
|
||||
background: inherit;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#checkpoint-arrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#checkpoint-arrow::before {
|
||||
#checkpoint-arrow::before,
|
||||
#checkpoint-arrow::after {
|
||||
visibility: visible;
|
||||
content: '';
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.checkpoint-popover_breadcrumb_active {
|
||||
@@ -43,20 +49,55 @@
|
||||
}
|
||||
|
||||
.checkpoint-popover[data-popper-placement^='top'] > #checkpoint-arrow {
|
||||
bottom: -6px;
|
||||
left: -$checkpoint-arrow-width !important;
|
||||
bottom: 1px;
|
||||
|
||||
&::after {
|
||||
border-bottom: $checkpoint-arrow-transparent;
|
||||
border-top: $checkpoint-arrow-light-300;
|
||||
border-left: $checkpoint-arrow-transparent;
|
||||
border-right: $checkpoint-arrow-transparent;
|
||||
-webkit-filter: drop-shadow(0px 4px 2px rgba(0,0,0,0.1));
|
||||
filter: drop-shadow(0px 4px 2px rgba(0,0,0,0.1));
|
||||
}
|
||||
}
|
||||
|
||||
.checkpoint-popover[data-popper-placement^='bottom'] > #checkpoint-arrow {
|
||||
top: -14px;
|
||||
top: -36px;
|
||||
left: -$checkpoint-arrow-width !important;
|
||||
&::before {
|
||||
background: $brand;
|
||||
border-bottom: $checkpoint-arrow-brand;
|
||||
border-top: $checkpoint-arrow-transparent;
|
||||
border-left: $checkpoint-arrow-transparent;
|
||||
border-right: $checkpoint-arrow-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.checkpoint-popover[data-popper-placement^='left'] > #checkpoint-arrow {
|
||||
right: -6px;
|
||||
top: -$checkpoint-arrow-width !important;
|
||||
right: 1px;
|
||||
|
||||
&::after {
|
||||
border-bottom: $checkpoint-arrow-transparent;
|
||||
border-top: $checkpoint-arrow-transparent;
|
||||
border-left: $checkpoint-arrow-light-300;
|
||||
border-right: $checkpoint-arrow-transparent;
|
||||
-webkit-filter: drop-shadow(3px 1px 2px rgba(0,0,0,0.1));
|
||||
filter: drop-shadow(3px 1px 2px rgba(0,0,0,0.1));
|
||||
}
|
||||
}
|
||||
|
||||
.checkpoint-popover[data-popper-placement^='right'] > #checkpoint-arrow {
|
||||
left: -6px;
|
||||
top: $checkpoint-arrow-width !important;
|
||||
left: 1px;
|
||||
|
||||
&::after {
|
||||
left: -2 * $checkpoint-arrow-width;
|
||||
border-bottom: $checkpoint-arrow-transparent;
|
||||
border-top: $checkpoint-arrow-transparent;
|
||||
border-left: $checkpoint-arrow-transparent;
|
||||
border-right: $checkpoint-arrow-light-300;
|
||||
-webkit-filter: drop-shadow(-3px 1px 2px rgba(0,0,0,0.1));
|
||||
filter: drop-shadow(-3px 1px 2px rgba(0,0,0,0.1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export default function CheckpointActionRow({
|
||||
{!isLastCheckpoint && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
@@ -24,9 +23,9 @@ export default function CheckpointActionRow({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
id="checkpoint-primary-button"
|
||||
autoFocus
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={isLastCheckpoint ? onEnd : onAdvance}
|
||||
>
|
||||
{isLastCheckpoint ? endButtonText : advanceButtonText}
|
||||
@@ -46,9 +45,9 @@ CheckpointActionRow.defaultProps = {
|
||||
};
|
||||
|
||||
CheckpointActionRow.propTypes = {
|
||||
advanceButtonText: PropTypes.string,
|
||||
dismissButtonText: PropTypes.string,
|
||||
endButtonText: PropTypes.string,
|
||||
advanceButtonText: PropTypes.node,
|
||||
dismissButtonText: PropTypes.node,
|
||||
endButtonText: PropTypes.node,
|
||||
isLastCheckpoint: PropTypes.bool,
|
||||
onAdvance: PropTypes.func,
|
||||
onDismiss: PropTypes.func,
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function CheckpointBreadcrumbs({ currentIndex, totalCheckpoints }
|
||||
<span className="d-flex align-items-center" aria-hidden focusable={false}>
|
||||
{new Array(totalCheckpoints).fill(0).map((v, i) => (
|
||||
<svg key={Math.random().toString(36).substr(2, 9)} aria-hidden focusable={false} role="img" width="14px" height="14px" viewBox="0 0 14 14">
|
||||
{i === currentIndex ? <circle className="checkpoint-popover_breadcrumb checkpoint-popover_breadcrumb_active" cx="7" cy="7" r="3px" />
|
||||
: <circle className="checkpoint-popover_breadcrumb checkpoint-popover_breadcrumb_inactive" cx="7" cy="7" r="2.5px" />}
|
||||
{i === currentIndex ? <circle className="checkpoint-popover_breadcrumb checkpoint-popover_breadcrumb_active" data-testid="checkpoint-popover_breadcrumb_active" cx="7" cy="7" r="3px" />
|
||||
: <circle className="checkpoint-popover_breadcrumb checkpoint-popover_breadcrumb_inactive" data-testid="checkpoint-popover_breadcrumb_inactive" cx="7" cy="7" r="2.5px" />}
|
||||
</svg>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -96,6 +96,8 @@ The text displayed on the button used to end the tour.
|
||||
A function that would be triggered when triggering the `onClick` event of the dismiss button.
|
||||
- **onEnd** `func`:
|
||||
A function that would be triggered when triggering the `onClick` event of the end button.
|
||||
- **onEscape** `func`:
|
||||
A function that would be triggered when pressing the Escape key.
|
||||
- **startingIndex** `number`:
|
||||
The index of the desired `Checkpoint` to render when the tour starts.
|
||||
- **tourId** `string` *required*
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { createPopper } from '@popperjs/core';
|
||||
|
||||
import Checkpoint from './Checkpoint';
|
||||
|
||||
@@ -9,79 +7,49 @@ function Tour({
|
||||
tours,
|
||||
}) {
|
||||
const tourValue = tours.filter((tour) => tour.enabled)[0];
|
||||
|
||||
const [currentCheckpointData, setCurrentCheckpointData] = useState(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [checkpointData, setCheckpointData] = useState(null);
|
||||
const [isEnabled, setIsEnabled] = useState(tourValue && tourValue.enabled);
|
||||
const [hideCheckpoint, setHideCheckpoint] = useState(false);
|
||||
const [isTourEnabled, setIsTourEnabled] = useState(!!tourValue);
|
||||
const [prunedCheckpoints, setPrunedCheckpoints] = useState([]);
|
||||
|
||||
/**
|
||||
* Takes a list of checkpoints and verifies that each target string provided is
|
||||
* an element in the DOM.
|
||||
*/
|
||||
const pruneCheckpoints = (checkpoints) => {
|
||||
const checkpointsWithRenderedTargets = checkpoints.filter(
|
||||
(checkpoint) => !!document.querySelector(checkpoint.target),
|
||||
);
|
||||
setPrunedCheckpoints(checkpointsWithRenderedTargets);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tourValue) {
|
||||
setCheckpointData(tourValue.checkpoints[index]);
|
||||
if (!isTourEnabled) {
|
||||
setIsTourEnabled(tourValue.enabled);
|
||||
}
|
||||
pruneCheckpoints(tourValue.checkpoints);
|
||||
setIndex(tourValue.startingIndex || 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(tourValue && tourValue.enabled);
|
||||
}, [tourValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tourValue) {
|
||||
setCheckpointData(tourValue.checkpoints[index]);
|
||||
}
|
||||
}, [index, isEnabled]);
|
||||
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 768px)' });
|
||||
|
||||
useEffect(() => {
|
||||
if (checkpointData && isEnabled) {
|
||||
const targetElement = document.querySelector(checkpointData.target);
|
||||
const checkpoint = document.querySelector('#checkpoint');
|
||||
if (!targetElement) {
|
||||
setHideCheckpoint(true);
|
||||
if (isTourEnabled) {
|
||||
if (prunedCheckpoints) {
|
||||
setCurrentCheckpointData(prunedCheckpoints[index]);
|
||||
} else {
|
||||
setHideCheckpoint(false);
|
||||
createPopper(targetElement, checkpoint, {
|
||||
placement: isMobile ? 'top' : checkpointData.placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'arrow',
|
||||
options: {
|
||||
padding: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let targetOffset = targetElement.getBoundingClientRect().top;
|
||||
if (checkpointData.placement && checkpointData.placement.includes('top')) {
|
||||
if (targetOffset < 0) {
|
||||
targetOffset *= -1;
|
||||
}
|
||||
targetOffset -= 280;
|
||||
} else {
|
||||
targetOffset -= 80;
|
||||
}
|
||||
|
||||
window.scrollTo({
|
||||
top: targetOffset, behavior: 'smooth',
|
||||
});
|
||||
pruneCheckpoints(tourValue.checkpoints);
|
||||
}
|
||||
}
|
||||
}, [checkpointData, index, isMobile]);
|
||||
}, [index, isTourEnabled, prunedCheckpoints]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (event) => {
|
||||
if (isEnabled && event.keyCode === 27) {
|
||||
setIsEnabled(false);
|
||||
if (tourValue.onEnd) {
|
||||
tourValue.onEnd();
|
||||
if (isTourEnabled && event.keyCode === 27) {
|
||||
setIsTourEnabled(false);
|
||||
if (tourValue.onEscape) {
|
||||
tourValue.onEscape();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -90,50 +58,54 @@ function Tour({
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [tourValue]);
|
||||
}, [currentCheckpointData]);
|
||||
|
||||
if (!tourValue || !checkpointData || !isEnabled) {
|
||||
if (!tourValue || !currentCheckpointData || !isTourEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAdvance = () => {
|
||||
setIndex(index + 1);
|
||||
if (checkpointData.onAdvance) {
|
||||
checkpointData.onAdvance();
|
||||
if (currentCheckpointData.onAdvance) {
|
||||
currentCheckpointData.onAdvance();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIndex(0);
|
||||
setIsEnabled(false);
|
||||
if (checkpointData.onDismiss) {
|
||||
checkpointData.onDismiss();
|
||||
setIsTourEnabled(false);
|
||||
if (currentCheckpointData.onDismiss) {
|
||||
currentCheckpointData.onDismiss();
|
||||
} else {
|
||||
tourValue.onDismiss();
|
||||
}
|
||||
setCurrentCheckpointData(null);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIndex(0);
|
||||
setIsEnabled(false);
|
||||
setIsTourEnabled(false);
|
||||
if (tourValue.onEnd) {
|
||||
tourValue.onEnd();
|
||||
}
|
||||
setCurrentCheckpointData(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkpoint
|
||||
advanceButtonText={checkpointData.advanceButtonText || tourValue.advanceButtonText}
|
||||
body={checkpointData.body}
|
||||
dismissButtonText={checkpointData.dismissButtonText || tourValue.dismissButtonText}
|
||||
endButtonText={checkpointData.endButtonText || tourValue.endButtonText}
|
||||
hideCheckpoint={hideCheckpoint}
|
||||
advanceButtonText={currentCheckpointData.advanceButtonText || tourValue.advanceButtonText}
|
||||
body={currentCheckpointData.body}
|
||||
currentCheckpointData={currentCheckpointData}
|
||||
dismissButtonText={currentCheckpointData.dismissButtonText || tourValue.dismissButtonText}
|
||||
endButtonText={currentCheckpointData.endButtonText || tourValue.endButtonText}
|
||||
index={index}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
title={checkpointData.title}
|
||||
totalCheckpoints={tourValue.checkpoints.length}
|
||||
placement={currentCheckpointData.placement}
|
||||
target={currentCheckpointData.target}
|
||||
title={currentCheckpointData.title}
|
||||
totalCheckpoints={prunedCheckpoints.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +127,7 @@ Tour.defaultProps = {
|
||||
endButtonText: '',
|
||||
onDismiss: () => {},
|
||||
onEnd: () => {},
|
||||
onEscape: () => {},
|
||||
startingIndex: 0,
|
||||
},
|
||||
};
|
||||
@@ -163,10 +136,10 @@ Tour.propTypes = {
|
||||
tours: PropTypes.arrayOf(PropTypes.shape({
|
||||
advanceButtonText: PropTypes.node,
|
||||
checkpoints: PropTypes.arrayOf(PropTypes.shape({
|
||||
advanceButtonText: PropTypes.string,
|
||||
body: PropTypes.string,
|
||||
dismissButtonText: PropTypes.string,
|
||||
endButtonText: PropTypes.string,
|
||||
advanceButtonText: PropTypes.node,
|
||||
body: PropTypes.node,
|
||||
dismissButtonText: PropTypes.node,
|
||||
endButtonText: PropTypes.node,
|
||||
onAdvance: PropTypes.func,
|
||||
onDismiss: PropTypes.func,
|
||||
placement: PropTypes.oneOf([
|
||||
@@ -174,13 +147,14 @@ Tour.propTypes = {
|
||||
'left-start', 'left', 'left-end', 'bottom', 'bottom-start', 'bottom-end',
|
||||
]),
|
||||
target: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
title: PropTypes.node,
|
||||
})),
|
||||
dismissButtonText: PropTypes.node,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
endButtonText: PropTypes.node,
|
||||
onDismiss: PropTypes.func,
|
||||
onEnd: PropTypes.func,
|
||||
onEscape: PropTypes.func,
|
||||
startingIndex: PropTypes.number,
|
||||
tourId: PropTypes.string.isRequired,
|
||||
})),
|
||||
|
||||
@@ -1,124 +1,143 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import Enzyme, { mount } from 'enzyme';
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as popper from '@popperjs/core';
|
||||
|
||||
import Checkpoint from '../Checkpoint';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
const popperMock = jest.spyOn(popper, 'createPopper');
|
||||
|
||||
describe('Checkpoint', () => {
|
||||
const handleAdvance = jest.fn();
|
||||
const handleDismiss = jest.fn();
|
||||
const handleEnd = jest.fn();
|
||||
|
||||
describe('second Checkpoint in Tour', () => {
|
||||
const secondCheckpointWrapper = mount((
|
||||
<Checkpoint
|
||||
advanceButtonText="Next"
|
||||
body="Lorem ipsum checkpoint body"
|
||||
dismissButtonText="Dismiss"
|
||||
endButtonText="End"
|
||||
index={1}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
title="Checkpoint title"
|
||||
totalCheckpoints={5}
|
||||
/>
|
||||
));
|
||||
beforeEach(() => {
|
||||
popperMock.mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
it('renders correct active breadcrumb', () => {
|
||||
const breadcrumbs = secondCheckpointWrapper.find('svg');
|
||||
afterEach(() => {
|
||||
popperMock.mockReset();
|
||||
});
|
||||
|
||||
describe('second Checkpoint in Tour', () => {
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<>
|
||||
<div id="target-element">...</div>
|
||||
<Checkpoint
|
||||
advanceButtonText="Next"
|
||||
body="Lorem ipsum checkpoint body"
|
||||
dismissButtonText="Dismiss"
|
||||
endButtonText="End"
|
||||
index={1}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
target="#target-element"
|
||||
title="Checkpoint title"
|
||||
totalCheckpoints={5}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders correct active breadcrumb', async () => {
|
||||
expect(screen.getByText('Checkpoint title')).toBeInTheDocument();
|
||||
const breadcrumbs = screen.getAllByTestId('checkpoint-popover_breadcrumb_', { exact: false });
|
||||
expect(breadcrumbs.length).toEqual(5);
|
||||
expect(breadcrumbs.at(0).exists('.checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(1).exists('.checkpoint-popover_breadcrumb_active')).toBe(true);
|
||||
expect(breadcrumbs.at(2).exists('.checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(3).exists('.checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(4).exists('.checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(0).classList.contains('checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(1).classList.contains('checkpoint-popover_breadcrumb_active')).toBe(true);
|
||||
expect(breadcrumbs.at(2).classList.contains('checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(3).classList.contains('checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
expect(breadcrumbs.at(4).classList.contains('checkpoint-popover_breadcrumb_inactive')).toBe(true);
|
||||
});
|
||||
|
||||
it('only renders advance and dismiss buttons (i.e. does not render end button)', () => {
|
||||
const buttons = secondCheckpointWrapper.find('button');
|
||||
expect(buttons.length).toEqual(2);
|
||||
|
||||
const dismissButton = buttons.at(0);
|
||||
expect(dismissButton.text()).toEqual('Dismiss');
|
||||
|
||||
const advanceButton = buttons.at(1);
|
||||
expect(advanceButton.text()).toEqual('Next');
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismiss button onClick calls handleDismiss', () => {
|
||||
const dismissButton = secondCheckpointWrapper.find('button').at(0);
|
||||
dismissButton.simulate('click');
|
||||
const dismissButton = screen.getByRole('button', { name: 'Dismiss' });
|
||||
fireEvent.click(dismissButton);
|
||||
expect(handleDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('advance button onClick calls handleAdvance', () => {
|
||||
const advanceButton = secondCheckpointWrapper.find('button').at(1);
|
||||
advanceButton.simulate('click');
|
||||
const advanceButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(advanceButton);
|
||||
expect(handleAdvance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('last Checkpoint in Tour', () => {
|
||||
const lastCheckpointWrapper = mount((
|
||||
<Checkpoint
|
||||
advanceButtonText="Next"
|
||||
body="Lorem ipsum checkpoint body"
|
||||
dismissButtonText="Dismiss"
|
||||
endButtonText="End"
|
||||
index={4}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
title="Checkpoint title"
|
||||
totalCheckpoints={5}
|
||||
/>
|
||||
));
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<>
|
||||
<div id="#last-element" />
|
||||
<Checkpoint
|
||||
advanceButtonText="Next"
|
||||
body="Lorem ipsum checkpoint body"
|
||||
dismissButtonText="Dismiss"
|
||||
endButtonText="End"
|
||||
index={4}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
target="#last-element"
|
||||
title="Checkpoint title"
|
||||
totalCheckpoints={5}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
it('only renders end button (i.e. neither advance nor dismiss buttons)', () => {
|
||||
const endButton = lastCheckpointWrapper.find('button');
|
||||
expect(endButton.exists()).toBe(true);
|
||||
expect(endButton.text()).toEqual('End');
|
||||
expect(screen.getByRole('button', { name: 'End' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('end button onClick calls handleEnd', () => {
|
||||
const endButton = lastCheckpointWrapper.find('button');
|
||||
endButton.simulate('click');
|
||||
const endButton = screen.getByRole('button', { name: 'End' });
|
||||
fireEvent.click(endButton);
|
||||
expect(handleEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('only one Checkpoint in Tour', () => {
|
||||
const singleCheckpointWrapper = mount((
|
||||
<Checkpoint
|
||||
advanceButtonText="Next"
|
||||
body="Lorem ipsum checkpoint body"
|
||||
dismissButtonText="Dismiss"
|
||||
endButtonText="End"
|
||||
index={0}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
title="Checkpoint title"
|
||||
totalCheckpoints={1}
|
||||
/>
|
||||
));
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<>
|
||||
<div id="#target-element" />
|
||||
<Checkpoint
|
||||
advanceButtonText="Next"
|
||||
body="Lorem ipsum checkpoint body"
|
||||
dismissButtonText="Dismiss"
|
||||
endButtonText="End"
|
||||
index={0}
|
||||
onAdvance={handleAdvance}
|
||||
onDismiss={handleDismiss}
|
||||
onEnd={handleEnd}
|
||||
target="#target-element"
|
||||
title="Checkpoint title"
|
||||
totalCheckpoints={1}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
it('only renders end button (i.e. neither advance nor dismiss buttons)', () => {
|
||||
const endButton = singleCheckpointWrapper.find('button');
|
||||
expect(endButton.length).toEqual(1);
|
||||
expect(endButton.exists()).toBe(true);
|
||||
expect(endButton.text()).toEqual('End');
|
||||
expect(screen.getByRole('button', { name: 'End' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render breadcrumbs', () => {
|
||||
expect(singleCheckpointWrapper.exists('.checkpoint-popover_breadcrumb_inactive')).toBe(false);
|
||||
expect(singleCheckpointWrapper.exists('.checkpoint-popover_breadcrumb_active')).toBe(false);
|
||||
const breadcrumbs = screen.queryAllByTestId('checkpoint-popover_breadcrumb_', { exact: false });
|
||||
expect(breadcrumbs.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import Enzyme, { mount } from 'enzyme';
|
||||
import Enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
@@ -9,8 +9,9 @@ import * as popper from '@popperjs/core';
|
||||
|
||||
import Tour from '../Tour';
|
||||
|
||||
// This can be removed once the component is ported over to Paragon
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
Enzyme.configure({ adapter: new Adapter() }); // This can be removed once the component is ported over to Paragon
|
||||
|
||||
const popperMock = jest.spyOn(popper, 'createPopper');
|
||||
|
||||
describe('Tour', () => {
|
||||
const targets = (
|
||||
@@ -77,6 +78,14 @@ describe('Tour', () => {
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
popperMock.mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
popperMock.mockReset();
|
||||
});
|
||||
|
||||
describe('multiple enabled tours', () => {
|
||||
it('renders first enabled tour', () => {
|
||||
const secondEnabledTourData = {
|
||||
@@ -96,7 +105,7 @@ describe('Tour', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const tourWrapper = mount(
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[disabledTourData, tourData, secondEnabledTourData]}
|
||||
@@ -105,16 +114,15 @@ describe('Tour', () => {
|
||||
</>,
|
||||
);
|
||||
|
||||
const checkpointTitle = tourWrapper.find('h2');
|
||||
expect(checkpointTitle.text()).toEqual('Checkpoint 1');
|
||||
expect(checkpointTitle.text()).not.toEqual('Second enabled tour');
|
||||
expect(screen.getByText('Checkpoint 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second enabled tour')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled tour', () => {
|
||||
describe('with default settings', () => {
|
||||
it('renders checkpoint with correct title, body, and breadcrumbs', () => {
|
||||
const tourWrapper = mount(
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[tourData]}
|
||||
@@ -122,14 +130,14 @@ describe('Tour', () => {
|
||||
{targets}
|
||||
</>,
|
||||
);
|
||||
const checkpoint = tourWrapper.find('#checkpoint');
|
||||
const checkpointTitle = checkpoint.find('h2');
|
||||
expect(checkpointTitle.text()).toEqual('Checkpoint 1');
|
||||
expect(checkpoint.find('svg').at(0).exists('.checkpoint-popover_breadcrumb_active')).toBe(true);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Checkpoint 1' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Checkpoint 1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkpoint-popover_breadcrumb_active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onClick of advance button advances to next checkpoint', () => {
|
||||
const tourWrapper = mount(
|
||||
it('onClick of advance button advances to next checkpoint', async () => {
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[tourData]}
|
||||
@@ -139,24 +147,18 @@ describe('Tour', () => {
|
||||
);
|
||||
|
||||
// Verify the first Checkpoint has rendered
|
||||
const firstCheckpoint = tourWrapper.find('#checkpoint');
|
||||
const firstCheckpointTitle = firstCheckpoint.find('h2');
|
||||
expect(firstCheckpointTitle.text()).toEqual('Checkpoint 1');
|
||||
expect(screen.getByRole('heading', { name: 'Checkpoint 1' })).toBeInTheDocument();
|
||||
|
||||
// Click the advance button
|
||||
const advanceButton = tourWrapper.find('button').at(1);
|
||||
expect(advanceButton.text()).toEqual('Next');
|
||||
|
||||
advanceButton.simulate('click');
|
||||
const advanceButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(advanceButton);
|
||||
|
||||
// Verify the second Checkpoint has rendered
|
||||
const secondCheckpoint = tourWrapper.find('#checkpoint');
|
||||
const secondCheckpointTitle = secondCheckpoint.find('h2');
|
||||
expect(secondCheckpointTitle.text()).toEqual('Checkpoint 2');
|
||||
expect(screen.getByRole('heading', { name: 'Checkpoint 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onClick of dismiss button disables tour', () => {
|
||||
const tourWrapper = mount(
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[tourData]}
|
||||
@@ -166,20 +168,19 @@ describe('Tour', () => {
|
||||
);
|
||||
|
||||
// Verify a Checkpoint has rendered
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'Checkpoint 1' })).toBeInTheDocument();
|
||||
|
||||
// Click the dismiss button
|
||||
const dismissButton = tourWrapper.find('button').at(0);
|
||||
expect(dismissButton.text()).toEqual('Dismiss');
|
||||
|
||||
dismissButton.simulate('click');
|
||||
const dismissButton = screen.getByRole('button', { name: 'Dismiss' });
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
// Verify no Checkpoints have rendered
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(false);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onClick of end button disables tour', () => {
|
||||
const tourWrapper = mount(
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[tourData]}
|
||||
@@ -189,44 +190,36 @@ describe('Tour', () => {
|
||||
);
|
||||
|
||||
// Verify a Checkpoint has rendered
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'Checkpoint 1' })).toBeInTheDocument();
|
||||
|
||||
// Advance the Tour to the last Checkpoint
|
||||
const advanceButton = tourWrapper.find('button').at(1);
|
||||
advanceButton.simulate('click');
|
||||
const advanceButton1 = tourWrapper.find('button').at(1);
|
||||
advanceButton1.simulate('click');
|
||||
const advanceButton2 = tourWrapper.find('button').at(1);
|
||||
advanceButton2.simulate('click');
|
||||
const advanceButton1 = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(advanceButton1);
|
||||
const advanceButton2 = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(advanceButton2);
|
||||
const advanceButton3 = screen.getByRole('button', { name: 'Override advance' });
|
||||
fireEvent.click(advanceButton3);
|
||||
|
||||
// Click the end button
|
||||
const endButton = tourWrapper.find('button');
|
||||
expect(endButton.text()).toEqual('Override end');
|
||||
|
||||
endButton.simulate('click');
|
||||
const endButton = screen.getByRole('button', { name: 'Override end' });
|
||||
fireEvent.click(endButton);
|
||||
|
||||
// Verify no Checkpoints have rendered
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(false);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onClick of escape key disables tour', () => {
|
||||
// React Testing Library would not play nice with createPopper
|
||||
// due to the order in which the Checkpoint renders. We'll mock
|
||||
// out the function here so this test can proceed as expected.
|
||||
const mock = jest.spyOn(popper, 'createPopper');
|
||||
mock.mockImplementation(jest.fn());
|
||||
|
||||
render(
|
||||
<div>
|
||||
<>
|
||||
<Tour
|
||||
tours={[tourData]}
|
||||
/>
|
||||
{targets}
|
||||
</div>,
|
||||
</>,
|
||||
);
|
||||
|
||||
// Verify a Checkpoint has rendered
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: 'Checkpoint 1' })).toBeInTheDocument();
|
||||
|
||||
// Click Escape key
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), {
|
||||
@@ -238,91 +231,111 @@ describe('Tour', () => {
|
||||
|
||||
// Verify no Checkpoints have been rendered
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
mock.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with Checkpoint override settings', () => {
|
||||
const overrideTourData = {
|
||||
advanceButtonText: 'Next',
|
||||
dismissButtonText: 'Dismiss',
|
||||
enabled: true,
|
||||
endButtonText: 'Okay',
|
||||
onDismiss: handleDismiss,
|
||||
onEnd: handleEnd,
|
||||
tourId: 'enabledTour',
|
||||
startingIndex: 2,
|
||||
checkpoints: [
|
||||
{
|
||||
body: 'Lorem ipsum body',
|
||||
target: '#target-1',
|
||||
title: 'Checkpoint 1',
|
||||
},
|
||||
{
|
||||
body: 'Lorem ipsum body',
|
||||
target: '#target-2',
|
||||
title: 'Checkpoint 2',
|
||||
},
|
||||
{
|
||||
body: 'Lorem ipsum body',
|
||||
target: '#target-3',
|
||||
title: 'Checkpoint 3',
|
||||
onDismiss: customOnDismiss,
|
||||
advanceButtonText: 'Override advance',
|
||||
dismissButtonText: 'Override dismiss',
|
||||
|
||||
},
|
||||
{
|
||||
target: '#target-3',
|
||||
title: 'Checkpoint 4',
|
||||
endButtonText: 'Override end',
|
||||
},
|
||||
],
|
||||
};
|
||||
it('renders correct checkpoint on index override', () => {
|
||||
const overrideTourData = tourData;
|
||||
overrideTourData.startingIndex = 2;
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[overrideTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(true);
|
||||
const checkpointTitle = tourWrapper.find('h2');
|
||||
expect(checkpointTitle.text()).toEqual('Checkpoint 3');
|
||||
expect(tourWrapper.find('svg').at(2).exists('.checkpoint-popover_breadcrumb_active'));
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: 'Checkpoint 3' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Checkpoint 3' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies override for advanceButtonText', () => {
|
||||
const overrideTourData = tourData;
|
||||
overrideTourData.startingIndex = 2;
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[overrideTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
const advanceButton = tourWrapper.find('button').at(1);
|
||||
expect(advanceButton.text()).toEqual('Override advance');
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Override advance' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies override for dismissButtonText', () => {
|
||||
const overrideTourData = tourData;
|
||||
overrideTourData.startingIndex = 2;
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[overrideTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
const dismissButton = tourWrapper.find('button').at(0);
|
||||
expect(dismissButton.text()).toEqual('Override dismiss');
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Override dismiss' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies override for endButtonText', () => {
|
||||
const overrideTourData = tourData;
|
||||
overrideTourData.startingIndex = 3;
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[overrideTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
|
||||
const endButton = tourWrapper.find('button');
|
||||
expect(endButton.text()).toEqual('Override end');
|
||||
</>,
|
||||
);
|
||||
const advanceButton = screen.getByRole('button', { name: 'Override advance' });
|
||||
fireEvent.click(advanceButton);
|
||||
expect(screen.getByRole('button', { name: 'Override end' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls customHandleDismiss onClick of dismiss button', () => {
|
||||
const overrideTourData = tourData;
|
||||
overrideTourData.startingIndex = 2;
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[overrideTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
const dismissButton = tourWrapper.find('button').at(0);
|
||||
expect(dismissButton.text()).toEqual('Override dismiss');
|
||||
dismissButton.simulate('click');
|
||||
</>,
|
||||
);
|
||||
const dismissButton = screen.getByRole('button', { name: 'Override dismiss' });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(customOnDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(false);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,34 +358,69 @@ describe('Tour', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[badTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
</>,
|
||||
);
|
||||
|
||||
const checkpoint = tourWrapper.find('#checkpoint');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(checkpoint.props().style.display).toEqual('none');
|
||||
it('advances to next valid Checkpoint', () => {
|
||||
const badTourData = {
|
||||
advanceButtonText: 'Next',
|
||||
dismissButtonText: 'Dismiss',
|
||||
enabled: true,
|
||||
endButtonText: 'Okay',
|
||||
onDismiss: handleDismiss,
|
||||
onEnd: handleEnd,
|
||||
tourId: 'badTour',
|
||||
checkpoints: [
|
||||
{
|
||||
body: 'Lorem ipsum body',
|
||||
target: 'bad-target-data',
|
||||
title: 'Checkpoint 1',
|
||||
},
|
||||
{
|
||||
body: 'Lorem ipsum body',
|
||||
target: '#target-1',
|
||||
title: 'Checkpoint 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[badTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Checkpoint 1' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: 'Checkpoint 2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Checkpoint 2' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled tour', () => {
|
||||
it('does not render', () => {
|
||||
const tourWrapper = mount((
|
||||
render(
|
||||
<>
|
||||
<Tour
|
||||
tours={[disabledTourData]}
|
||||
/>
|
||||
{targets}
|
||||
</>
|
||||
));
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(tourWrapper.exists('#checkpoint')).toBe(false);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user