- {upsellMessage}
+
+
+ {expirationBanner}
+
+ {upsellMessage}
+
+
+
+
+ {offerCode}
-
-
-
- {offerCode}
);
}
diff --git a/src/index.scss b/src/index.scss
index 781ab274..f764088b 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -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";
diff --git a/src/product-tours/AbandonTour.jsx b/src/product-tours/AbandonTour.jsx
new file mode 100644
index 00000000..2aed9f57
--- /dev/null
+++ b/src/product-tours/AbandonTour.jsx
@@ -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:
,
+ placement: 'left',
+ target: '#courseHome-launchTourLink',
+ }],
+ enabled,
+ endButtonText:
,
+ onEnd,
+ onEscape: onEnd,
+ tourId: 'abandonTour',
+});
+
+export default abandonTour;
diff --git a/src/product-tours/CoursewareTour.jsx b/src/product-tours/CoursewareTour.jsx
new file mode 100644
index 00000000..5ab591c6
--- /dev/null
+++ b/src/product-tours/CoursewareTour.jsx
@@ -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:
,
+ placement: 'bottom',
+ target: '#courseware-sequenceNavigation',
+ }],
+ enabled,
+ endButtonText:
,
+ onEnd,
+ onEscape: onEnd,
+ tourId: 'coursewareTour',
+});
+
+export default coursewareTour;
diff --git a/src/product-tours/ExistingUserCourseHomeTour.jsx b/src/product-tours/ExistingUserCourseHomeTour.jsx
new file mode 100644
index 00000000..d71a0acb
--- /dev/null
+++ b/src/product-tours/ExistingUserCourseHomeTour.jsx
@@ -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:
,
+ placement: 'left',
+ target: '#courseHome-launchTourLink',
+ }],
+ enabled,
+ endButtonText:
,
+ onEnd,
+ onEscape: onEnd,
+ tourId: 'existingUserCourseHomeTour',
+});
+
+export default existingUserCourseHomeTour;
diff --git a/src/product-tours/GenericTourFormattedMessages.jsx b/src/product-tours/GenericTourFormattedMessages.jsx
new file mode 100644
index 00000000..35154917
--- /dev/null
+++ b/src/product-tours/GenericTourFormattedMessages.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+export function DismissButtonFormattedMessage() {
+ return (
+
+ );
+}
+
+export function NextButtonFormattedMessage() {
+ return (
+
+ );
+}
+
+export function OkayButtonFormattedMessage() {
+ return (
+
+ );
+}
diff --git a/src/product-tours/ProductTours.jsx b/src/product-tours/ProductTours.jsx
new file mode 100644
index 00000000..81e0d201
--- /dev/null
+++ b/src/product-tours/ProductTours.jsx
@@ -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
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 (
+ <>
+
+
{
+ 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;
diff --git a/src/product-tours/ProductTours.test.jsx b/src/product-tours/ProductTours.test.jsx
new file mode 100644
index 00000000..37ec3a54
--- /dev/null
+++ b/src/product-tours/ProductTours.test.jsx
@@ -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(
+
+
+ ,
+ { 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 (
+ Unit Contents {courseId} {id}
+ );
+}
+
+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 = (
+
+
+
+
+
+
+
+ );
+ });
+
+ 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);
+});
+ });
+});
diff --git a/src/product-tours/data/api.js b/src/product-tours/data/api.js
new file mode 100644
index 00000000..10fc412c
--- /dev/null
+++ b/src/product-tours/data/api.js
@@ -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);
+}
diff --git a/src/product-tours/data/index.js b/src/product-tours/data/index.js
new file mode 100644
index 00000000..75ea5f38
--- /dev/null
+++ b/src/product-tours/data/index.js
@@ -0,0 +1,8 @@
+export {
+ closeNewUserCourseHomeModal,
+ endCourseHomeTour,
+ endCoursewareTour,
+ fetchTourData,
+} from './thunks';
+
+export { reducer } from './slice';
diff --git a/src/product-tours/data/slice.js b/src/product-tours/data/slice.js
new file mode 100644
index 00000000..504906d6
--- /dev/null
+++ b/src/product-tours/data/slice.js
@@ -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;
diff --git a/src/product-tours/data/thunks.js b/src/product-tours/data/thunks.js
new file mode 100644
index 00000000..3f5d65f1
--- /dev/null
+++ b/src/product-tours/data/thunks.js
@@ -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);
+ }
+ };
+}
diff --git a/src/product-tours/messages.js b/src/product-tours/messages.js
new file mode 100644
index 00000000..ce8f2d97
--- /dev/null
+++ b/src/product-tours/messages.js
@@ -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;
diff --git a/src/product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton.jsx b/src/product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton.jsx
new file mode 100644
index 00000000..00ca983d
--- /dev/null
+++ b/src/product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton.jsx
@@ -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 && (
+
+ {!srOnly && (
+
+ )}
+ {intl.formatMessage(messages.launchTour)}
+
+ )}
+ >
+ );
+}
+
+LaunchCourseHomeTourButton.defaultProps = {
+ metadataModel: 'courseHomeMeta',
+ srOnly: false,
+};
+
+LaunchCourseHomeTourButton.propTypes = {
+ intl: intlShape.isRequired,
+ metadataModel: PropTypes.string,
+ srOnly: PropTypes.bool,
+};
+
+export default injectIntl(LaunchCourseHomeTourButton);
diff --git a/src/product-tours/newUserCourseHomeTour/NewUserCourseHomeTour.jsx b/src/product-tours/newUserCourseHomeTour/NewUserCourseHomeTour.jsx
new file mode 100644
index 00000000..fbfa9273
--- /dev/null
+++ b/src/product-tours/newUserCourseHomeTour/NewUserCourseHomeTour.jsx
@@ -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: ,
+ placement: 'left-start',
+ target: '#courseHome-dates',
+ title: ,
+};
+
+const outlineCheckpoint = {
+ body: ,
+ placement: 'top',
+ target: '#courseHome-outline',
+ title: ,
+};
+
+const tabNavigationCheckpoint = {
+ body: ,
+ placement: 'bottom',
+ target: '#courseTabsNavigation',
+ title: ,
+};
+
+const upgradeCheckpoint = (logUpgradeClick, upgradeLink) => ({
+ body:
+
+
+ ),
+ }}
+ />,
+ placement: 'left-start',
+ target: '#courseHome-upgradeNotification',
+ title: ,
+});
+
+const weeklyGoalsCheckpoint = {
+ body: ,
+ placement: 'left',
+ target: '#courseHome-weeklyLearningGoal',
+ title: ,
+};
+
+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: ,
+ checkpoints: [
+ outlineCheckpoint,
+ datesCheckpoint,
+ tabNavigationCheckpoint,
+ upgradeCheckpoint(logUpgradeClick, upgradeData.upgradeUrl),
+ weeklyGoalsCheckpoint,
+ ],
+ dismissButtonText: ,
+ enabled,
+ endButtonText: ,
+ onDismiss,
+ onEnd,
+ onEscape: onDismiss,
+ tourId: 'newUserCourseHomeTour',
+ });
+};
+
+export default newUserCourseHomeTour;
diff --git a/src/product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.jsx b/src/product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.jsx
new file mode 100644
index 00000000..5fdfd14b
--- /dev/null
+++ b/src/product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.jsx
@@ -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 (
+
+
+
+
+ {intl.formatMessage(messages.newUserModalTitleWelcome)},
+ }}
+ />
+
+
+
+ )}
+ footerNode={(
+
+
+ {intl.formatMessage(messages.skipForNow)}
+
+
+ {intl.formatMessage(messages.beginTour)}
+
+
+ )}
+ onClose={onDismiss}
+ >
+ {intl.formatMessage(messages.newUserModalBody)}
+
+ );
+}
+
+NewUserCourseHomeTourModal.propTypes = {
+ intl: intlShape.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ onDismiss: PropTypes.func.isRequired,
+ onStartTour: PropTypes.func.isRequired,
+};
+
+export default injectIntl(NewUserCourseHomeTourModal);
diff --git a/src/product-tours/newUserCourseHomeTour/course_home_tour_modal_hero.png b/src/product-tours/newUserCourseHomeTour/course_home_tour_modal_hero.png
new file mode 100644
index 00000000..553c8c49
Binary files /dev/null and b/src/product-tours/newUserCourseHomeTour/course_home_tour_modal_hero.png differ
diff --git a/src/store.js b/src/store.js
index c7ede6e5..e29129f5 100644
--- a/src/store.js
+++ b/src/store.js
@@ -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,
},
});
}
diff --git a/src/tab-page/LoadedTabPage.jsx b/src/tab-page/LoadedTabPage.jsx
index 22a8563f..9ba67ac6 100644
--- a/src/tab-page/LoadedTabPage.jsx
+++ b/src/tab-page/LoadedTabPage.jsx
@@ -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 (
<>
+
{`${activeTab ? `${activeTab.title} | ` : ''}${title} | ${getConfig().SITE_NAME}`}
diff --git a/src/tab-page/LoadedTabPage.test.jsx b/src/tab-page/LoadedTabPage.test.jsx
index f75c47f0..cccbbe7a 100644
--- a/src/tab-page/LoadedTabPage.test.jsx
+++ b/src/tab-page/LoadedTabPage.test.jsx
@@ -6,6 +6,7 @@ import LoadedTabPage from './LoadedTabPage';
jest.mock('../course-header/CourseTabsNavigation', () => () =>
);
jest.mock('../instructor-toolbar/InstructorToolbar', () => () =>
);
jest.mock('../shared/streak-celebration/StreakCelebrationModal', () => () =>
);
+jest.mock('../product-tours/ProductTours', () => () =>
);
describe('Loaded Tab Page', () => {
const mockData = { activeTabSlug: 'courseware', metadataModel: 'coursewareMeta' };
diff --git a/src/tab-page/TabPage.jsx b/src/tab-page/TabPage.jsx
index 4d2c5c6f..bbc3e97a 100644
--- a/src/tab-page/TabPage.jsx
+++ b/src/tab-page/TabPage.jsx
@@ -77,6 +77,8 @@ function TabPage({ intl, ...props }) {
courseOrg={org}
courseNumber={number}
courseTitle={title}
+ metadataModel={metadataModel}
+ showLaunchTourLink
/>
diff --git a/src/tour/Checkpoint.jsx b/src/tour/Checkpoint.jsx
index a4db2fb5..ebe3e607 100644
--- a/src/tour/Checkpoint.jsx
+++ b/src/tour/Checkpoint.jsx
@@ -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 */}
Top of step {index + 1}
@@ -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,
};
diff --git a/src/tour/Checkpoint.scss b/src/tour/Checkpoint.scss
index 036b0871..70742550 100644
--- a/src/tour/Checkpoint.scss
+++ b/src/tour/Checkpoint.scss
@@ -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));
+ }
}
diff --git a/src/tour/CheckpointActionRow.jsx b/src/tour/CheckpointActionRow.jsx
index a7af35b0..3fbcd90c 100644
--- a/src/tour/CheckpointActionRow.jsx
+++ b/src/tour/CheckpointActionRow.jsx
@@ -16,7 +16,6 @@ export default function CheckpointActionRow({
{!isLastCheckpoint && (
@@ -24,9 +23,9 @@ export default function CheckpointActionRow({
)}
{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,
diff --git a/src/tour/CheckpointBreadcrumbs.jsx b/src/tour/CheckpointBreadcrumbs.jsx
index 38982291..d2d0cfb9 100644
--- a/src/tour/CheckpointBreadcrumbs.jsx
+++ b/src/tour/CheckpointBreadcrumbs.jsx
@@ -9,8 +9,8 @@ export default function CheckpointBreadcrumbs({ currentIndex, totalCheckpoints }
{new Array(totalCheckpoints).fill(0).map((v, i) => (
- {i === currentIndex ?
- : }
+ {i === currentIndex ?
+ : }
))}
diff --git a/src/tour/README.md b/src/tour/README.md
index ea98a7da..68e4cd96 100644
--- a/src/tour/README.md
+++ b/src/tour/README.md
@@ -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*
diff --git a/src/tour/Tour.jsx b/src/tour/Tour.jsx
index 9db9b2ee..3ed79b20 100644
--- a/src/tour/Tour.jsx
+++ b/src/tour/Tour.jsx
@@ -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 (
);
}
@@ -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,
})),
diff --git a/src/tour/tests/Checkpoint.test.jsx b/src/tour/tests/Checkpoint.test.jsx
index 4aa320b4..4d258e65 100644
--- a/src/tour/tests/Checkpoint.test.jsx
+++ b/src/tour/tests/Checkpoint.test.jsx
@@ -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((
-
- ));
+ 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(
+ <>
+ ...
+
+ >,
+ );
+ });
+
+ 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((
-
- ));
+ beforeEach(() => {
+ render(
+ <>
+
+
+ >,
+ );
+ });
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((
-
- ));
+ beforeEach(() => {
+ render(
+ <>
+
+
+ >,
+ );
+ });
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);
});
});
});
diff --git a/src/tour/tests/Tour.test.jsx b/src/tour/tests/Tour.test.jsx
index 626ad4d3..9b717bc7 100644
--- a/src/tour/tests/Tour.test.jsx
+++ b/src/tour/tests/Tour.test.jsx
@@ -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(
<>
{
>,
);
- 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(
<>
{
{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(
<>
{
);
// 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(
<>
{
);
// 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(
<>
{
);
// 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(
-
+ <>
{targets}
-
,
+ >,
);
// 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(
<>
{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(
<>
{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(
<>
{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(
<>
{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(
<>
{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(
<>
{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(
+ <>
+
+ {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(
<>
{targets}
- >
- ));
+ >,
+ );
- expect(tourWrapper.exists('#checkpoint')).toBe(false);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
});