feat: new user course home tour (AA-1027) (#750)

This commit is contained in:
Carla Duarte
2021-12-14 12:53:10 -05:00
committed by GitHub
parent 32e299e13b
commit 2fa4a837b1
43 changed files with 1599 additions and 327 deletions

View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,
};

View File

@@ -20,4 +20,5 @@ Factory.define('courseHomeMetadata')
},
start: '2013-02-05T05:00:00Z',
user_timezone: 'UTC',
username: 'testuser',
});

View File

@@ -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,
},
}
`;

View File

@@ -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}

View File

@@ -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(); });

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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"
>

View File

@@ -16,6 +16,7 @@
margin: 0;
border: none;
border-radius: 0;
z-index: 1100;
}
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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";

View 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;

View 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 whats coming up."
/>,
placement: 'bottom',
target: '#courseware-sequenceNavigation',
}],
enabled,
endButtonText: <OkayButtonFormattedMessage />,
onEnd,
onEscape: onEnd,
tourId: 'coursewareTour',
});
export default coursewareTour;

View 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="Weve 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;

View 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"
/>
);
}

View 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;

View 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('Weve 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);
});
});
});

View 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);
}

View File

@@ -0,0 +1,8 @@
export {
closeNewUserCourseHomeModal,
endCourseHomeTour,
endCoursewareTour,
fetchTourData,
} from './thunks';
export { reducer } from './slice';

View 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;

View 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);
}
};
}

View 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: 'Lets 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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
},
});
}

View File

@@ -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>

View File

@@ -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' };

View File

@@ -77,6 +77,8 @@ function TabPage({ intl, ...props }) {
courseOrg={org}
courseNumber={number}
courseTitle={title}
metadataModel={metadataModel}
showLaunchTourLink
/>
<LoadedTabPage {...props} />
<Footer />

View File

@@ -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,
};

View File

@@ -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));
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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*

View File

@@ -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,
})),

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});