Compare commits

...

38 Commits

Author SHA1 Message Date
Syed Ali Abbas Zaidi
b24568f0bd chore: bump frontend-platform (#1209) 2023-10-18 11:13:06 +05:00
renovate[bot]
5604def491 chore(deps): update dependency @edx/frontend-build to v12.9.17 2023-10-04 21:51:00 +00:00
Syed Ali Abbas Zaidi
b788b969c3 feat: upgrade react router to v6 (#1128)
* feat: upgrade react router to v6

* fix: all test cases affected by react router upgrade

* refactor: fix navigations

* fix: test cases affectewd due to lib-special-exams

* refactor: improve code coverage
2023-10-04 17:34:53 -04:00
Zachary Hancock
b7a3d5640a fix: special exams version fix (#1196) 2023-09-27 13:12:20 -04:00
Zachary Hancock
3a21d8c807 feat: update exams library (#1188) 2023-09-25 13:46:29 -04:00
alangsto
81442bebe9 feat: update learning assistant version (#1195) 2023-09-21 14:44:58 -04:00
alangsto
168ed1e184 feat: upgrade learning assistant version (#1187) 2023-09-18 13:27:57 -04:00
Ben Warzeski
c8e32c3f46 feat: allow override of plugin.modal height (#1184) 2023-09-15 10:12:47 -04:00
Bilal Qamar
51dd90741b feat: update react & react-dom to v17 (#1127)
* feat: update react & react-dom to v17

* build: update paragon version

* refactor: updated edx packages

* refactor: updated react-unit-test-utils

* build: update lock file

* build: update lock file

* build: update lock file

* build: update lock file

* refactor: bumped frontend-lib-learning-assistant version

---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-09-11 10:53:32 -04:00
Michael Roytman
f58d6d6d25 feat: add Segment event for rendering Xpert and bump frontend-lib-learning-assistant version (#1182)
In order to diagnose low usage rates, we're temporarily adding Segment events for rendering the chat and for closing the call-to-action message. This will help us determine whether Xpert is being successfully rendered and whether Xpert is being ignored by tracking whether learners close the call-to-action message at a high rate.

At a minimum, we expect to remove the Segment event for rendering the Xpert, because this will be a very noisy Segment event. We plan to leave it in for a few days just to accumulate data. We will evaluate whether to keep the call-to-action dismissal Segment event based on the data.

This commit adds the Segment event for rendering the Xpert. This commit also installs version 1.11.1 of frontend-lib-learning-assistant, which introduces a Segment event for for closing the call-to-action message.
2023-09-08 16:11:41 -04:00
alangsto
81a49bd755 feat: grant audit learners access (#1180) 2023-09-07 13:47:04 -04:00
Pierre Mailhot
2ae033160f fix: alert.start.long and alert.start.calendar (#1173)
They are currently using end, but since we are in the course-start-alert file it should be start instead.
2023-09-07 11:18:35 -04:00
alangsto
32bd3190a6 fix: prevent content tools from displaying over chat sidebar (#1179) 2023-08-31 17:09:16 -04:00
Artur Gaspar
645ac2cb5f fix: toggle notes visibility button state correctly (#1170) 2023-08-31 10:36:53 -04:00
Michael Roytman
ee80b24cba feat: install new version of frontend-lib-learning-assistant to conditionally render toggle elements (#1178)
This commit installs version 1.9.3 of @edx/frontend-lib-learning-assistant, which includes a UI tweak to conditionally render the Xpert toggle button and action message (call-to-action) only when the Xpert sidebar is closed.
2023-08-30 11:53:23 -04:00
alangsto
ee1d816cc8 feat: update frontend-lib-learning-assistant (#1177) 2023-08-30 08:48:22 -04:00
alangsto
e8ac2ffc7e feat: update frontend-lib-learning-assistant (#1176) 2023-08-29 17:01:38 -04:00
alangsto
62d3e95cc8 feat: update learning assistant version (#1175) 2023-08-29 15:47:02 -04:00
Michael Roytman
ce6771d7cc feat: install new version of frontend-lib-learning-assistant to add UI tweaks (#1174)
This commit installs version 1.8.0 of @edx/frontend-lib-learning-assistant, which includes a various UI tweaks.
2023-08-29 14:08:39 -04:00
alangsto
1dcde821b4 feat: upgrade learning assistant library (#1172) 2023-08-28 13:38:19 -04:00
Michael Roytman
694e3ed6d5 feat: install new version of frontend-lib-learning-assistant and add Privacy Policy URL to config (#1171)
This commit installs version 1.6.0 of @edx/frontend-lib-learning-assistant, which includes a new user disclosure feature. This commit also includes the Privacy Policy URL to the frontend-app-learning config, because the Xpert learning assistant uses this config value in the disclosure.
2023-08-28 10:44:27 -04:00
alangsto
ba843622c2 feat: update version of the learning assistant frontend (#1168) 2023-08-24 14:19:19 -04:00
Michael Roytman
2d29827e6b feat: install Xpert chatbot from frontend-lib-learning-assistant (#1166)
This commit installs the Xpert chatbot feature from the frontend-lib-learning-assistant repository into the frontend-lib-learning application.

This component is rendered by the Course component. The component is only rendered when a few conditions are satisfied.
2023-08-23 09:14:14 -04:00
sundasnoreen12
2b9b3db5d3 refactor: refactor code by creating 1 instance of header instead of 3 (#1164)
* refactor: refactor code by creating single instance of header instead of 3

* refactor: refactor courseStatus comparsion code

---------

Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-08-23 14:46:47 +05:00
Awais Ansari
2e90e214b4 fix: should not fetch courseTopics for lti provider (#1165)
* fix: should not fetch courseTopics for lti provider

* refactor: fetch course topics when provider is edx
2023-08-22 12:44:31 +05:00
alangsto
ea2d7ed839 feat: add config value for chat response url (#1163) 2023-08-18 08:52:25 -04:00
Mohamed Akram
5ee61904d5 fix: make nav buttons use links for accessibility (#1137) 2023-08-14 13:18:55 -04:00
Zachary Hancock
6232b0cb98 Revert "Revert "feat: update special exams lib (#1152)" (#1154)" (#1161)
This reverts commit 1871e491a7.

The original PR was reverted due to it's deploy coinciding with the app breaking. Turns out it was not the root cause.
2023-08-10 10:30:22 -04:00
Zachary Hancock
09542338a2 feat: rebuild package lock (#1160)
* feat: rebuild package lock

* feat: update paragon

* test: fix axios/jest incompatibility
2023-08-09 13:29:59 -04:00
renovate[bot]
c3d345e642 chore(deps): update dependency @edx/reactifex to v2.2.0 2023-08-09 04:24:16 +00:00
renovate[bot]
ec2bf60345 chore(deps): update dependency @edx/browserslist-config to v1.2.0 2023-08-09 00:02:00 +00:00
Zachary Hancock
b0c71e5291 fix: error when navigating exam units (#1157)
* fix: incorrect usage of useEffect callback caused intermittent react errors
2023-08-08 17:34:05 -04:00
renovate[bot]
dcd6847254 fix(deps): update dependency reselect to v4.1.8 2023-08-08 18:41:06 +00:00
renovate[bot]
d2df9241c3 chore(deps): update dependency @edx/frontend-build to v12.9.4 2023-08-08 15:56:08 +00:00
Zachary Hancock
1871e491a7 Revert "feat: update special exams lib (#1152)" (#1154)
This reverts commit 0c49658314.
2023-08-08 11:23:12 -04:00
renovate[bot]
03543c0af1 fix(deps): update dependency js-cookie to v3.0.5 2023-08-08 14:33:17 +00:00
Zachary Hancock
0c49658314 feat: update special exams lib (#1152) 2023-08-08 09:04:58 -04:00
Demid
2a1173584e fix: make iframe wrapper take all vieport width (#1094) 2023-07-31 12:21:56 -04:00
82 changed files with 5303 additions and 6401 deletions

1
.env
View File

@@ -46,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -46,3 +46,6 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -45,3 +45,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'

View File

@@ -9,6 +9,12 @@ module.exports = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000,
testEnvironment: 'jsdom'
});

9393
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,43 +30,44 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-lib-special-exams": "2.19.1",
"@edx/frontend-platform": "4.3.0",
"@edx/paragon": "20.28.4",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.5.3",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.14.0",
"@edx/frontend-lib-special-exams": "2.23.2",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.18",
"@fortawesome/react-fontawesome": "^0.1.4",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"js-cookie": "3.0.1",
"js-cookie": "3.0.5",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "16.14.0",
"react-dom": "16.14.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"reselect": "4.1.8",
"truncate-html": "1.0.4",
"util": "0.12.5"
},
"devDependencies": {
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.8.27",
"@edx/reactifex": "2.1.1",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "^12.9.10",
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",

View File

@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
<Alert variant="info" icon={Info}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
id="learning.outline.alert.start.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
@@ -88,7 +88,7 @@ const CourseStartAlert = ({ payload }) => {
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.end.calendar"
id="learning.outline.alert.start.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/>

33
src/constants.js Normal file
View File

@@ -0,0 +1,33 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -18,6 +18,9 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
@@ -336,6 +339,9 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
@@ -532,6 +538,9 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {

View File

@@ -67,7 +67,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
});
it.each([401, 403, 404])(
@@ -111,7 +118,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
});
it.each([401, 403, 404])(
@@ -156,7 +170,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
});
it('Should handle the url including a targetUserId', async () => {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Route } from 'react-router';
import { Routes, Route } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
@@ -32,11 +32,16 @@ describe('DatesTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
<Routes>
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -2,21 +2,20 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useParams, generatePath, useNavigate } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
const DiscussionTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
const history = useHistory();
const navigate = useNavigate();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
history.push(`${basePath}/${payload.path}`);
navigate(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;

View File

@@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Route } from 'react-router';
import { Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
@@ -30,11 +30,16 @@ describe('DiscussionTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/discussion">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</Route>
<Routes>
<Route
path="/course/:courseId/discussion"
element={(
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { Route } from 'react-router';
import {
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { getConfig, history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
@@ -24,13 +26,16 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<UserMessagesProvider>
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
</UserMessagesProvider>
</AppProvider>
);
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
});
it('starts with a spinner', () => {

View File

@@ -1,9 +1,8 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
@@ -67,6 +66,7 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = {
org_key: org,
@@ -115,8 +115,10 @@ const OutlineTab = ({ intl }) => {
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
history.replace({
search: currentParams.toString(),
navigate({
pathname: location.pathname,
search: `?${currentParams.toString()}`,
replace: true,
});
}
}, [location.search]);

View File

@@ -1,7 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect';
@@ -17,45 +16,46 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
import withParamsAndNavigation from './utils';
// Look at where this is called in componentDidUpdate for more info about its usage
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) {
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
} else if (firstSequenceId) {
history.replace(`/course/${courseId}/${firstSequenceId}`);
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
}
});
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
history.replace(`/course/${courseId}/${unitId}`);
navigate(`/course/${courseId}/${unitId}`, { replace: true });
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) {
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
// Otherwise, just go to the course root, letting the resume redirect take care of things.
} else {
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize(
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
@@ -64,60 +64,62 @@ const checkUnitToSequenceUnitRedirect = memoize(
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
} else {
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
}
},
() => { // error case
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
}
}
},
);
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
(courseId, sequenceStatus, sequence, unitId, navigate) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
} else {
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
}
}
});
},
);
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
@@ -145,12 +147,8 @@ class CoursewareContainer extends Component {
componentDidMount() {
const {
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
routeCourseId,
routeSequenceId,
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
@@ -167,13 +165,10 @@ class CoursewareContainer extends Component {
sequence,
firstSequenceId,
sectionViaSequenceId,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
routeCourseId,
routeSequenceId,
routeUnitId,
navigate,
} = this.props;
// Load data whenever the course or sequence ID changes.
@@ -202,7 +197,7 @@ class CoursewareContainer extends Component {
// Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
// based on sequence/unit where user was last active.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
// Check section-unit to unit redirect:
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
@@ -215,60 +210,54 @@ class CoursewareContainer extends Component {
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
// and `checkUnitToSequenceUnitRedirect`.
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
// Check section to sequence redirect:
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
// by redirecting to the first sequence within the section.
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
sequenceId, sectionViaSequenceId, routeUnitId, navigate
));
// Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
// Check sequence-unit marker to sequence-unit redirect:
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
// /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the first or last unit in the sequence.
// "Sequence unit marker" is an invented term used only in this component.
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
}
handleUnitNavigationClick = (nextUnitId) => {
handleUnitNavigationClick = () => {
const {
courseId, sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
courseId,
sequenceId,
routeUnitId,
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
};
handleNextSequenceClick = () => {
const {
course,
courseId,
nextSequence,
sequence,
sequenceId,
} = this.props;
if (nextSequence !== null) {
history.push(`/course/${courseId}/${nextSequence.id}/first`);
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id);
@@ -276,23 +265,14 @@ class CoursewareContainer extends Component {
}
};
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
history.push(`/course/${courseId}/${previousSequence.id}/last`);
}
};
handlePreviousSequenceClick = () => {};
render() {
const {
courseStatus,
courseId,
sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
routeUnitId,
} = this.props;
return (
@@ -335,13 +315,9 @@ const courseShape = PropTypes.shape({
});
CoursewareContainer.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
routeCourseId: PropTypes.string.isRequired,
routeSequenceId: PropTypes.string,
routeUnitId: PropTypes.string,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
@@ -357,11 +333,14 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
};
CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
routeSequenceId: null,
routeUnitId: null,
firstSequenceId: null,
nextSequence: null,
previousSequence: null,
@@ -476,4 +455,4 @@ export default connect(mapStateToProps, {
saveSequencePosition,
fetchCourse,
fetchSequence,
})(CoursewareContainer);
})(withParamsAndNavigation(CoursewareContainer));

View File

@@ -5,13 +5,16 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Route, Switch } from 'react-router';
import {
BrowserRouter, MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import { initializeMockApp, waitFor } from '../setupTest';
import { DECODE_ROUTES } from '../constants';
import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
@@ -80,18 +83,16 @@ describe('CoursewareContainer', () => {
store = initializeStore();
component = (
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<UserMessagesProvider>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
<Routes>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={<CoursewareContainer />}
/>
))}
</Routes>
</UserMessagesProvider>
</AppProvider>
);
@@ -151,7 +152,7 @@ describe('CoursewareContainer', () => {
}
async function loadContainer() {
const { container } = render(component);
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
// 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'));
@@ -160,7 +161,7 @@ describe('CoursewareContainer', () => {
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(component);
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
const spinner = screen.getByRole('status');
@@ -185,7 +186,7 @@ describe('CoursewareContainer', () => {
function assertSequenceNavigation(container, expectedUnitCount = 3) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
@@ -413,10 +414,10 @@ describe('CoursewareContainer', () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await waitFor(() => loadContainer());
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNavButtons[4]);
fireEvent.click(sequenceNextButton);
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
});

View File

@@ -1,56 +1,44 @@
import React from 'react';
import { Switch, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { Routes, Route } from 'react-router-dom';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageRoute } from '@edx/frontend-platform/react';
import { PageWrap } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading';
import DecodePageRoute from '../decode-page-route';
import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants';
import RedirectPage from './RedirectPage';
const CoursewareRedirectLandingPage = () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
const CoursewareRedirectLandingPage = () => (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
/>
<Switch>
<DecodePageRoute
path={`${path}/survey/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
}}
/>
<PageRoute
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}}
/>
<PageRoute
path={`${path}/consent/`}
render={({ location }) => {
const { consentPath } = queryString.parse(location.search);
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<DecodePageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
}}
/>
</Switch>
</div>
);
};
<Routes>
<Route
path={DECODE_ROUTES.REDIRECT_SURVEY}
element={<DecodePageRoute><RedirectPage pattern="/courses/:courseId/survey" mode={REDIRECT_MODES.SURVEY_REDIRECT} /></DecodePageRoute>}
/>
<Route
path={ROUTES.DASHBOARD}
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>}
/>
<Route
path={ROUTES.CONSENT}
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>}
/>
<Route
path={DECODE_ROUTES.REDIRECT_HOME}
element={<DecodePageRoute><RedirectPage pattern="/course/:courseId/home" mode={REDIRECT_MODES.HOME_REDIRECT} /></DecodePageRoute>}
/>
</Routes>
</div>
);
export default CoursewareRedirectLandingPage;

View File

@@ -1,19 +1,12 @@
import React from 'react';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { MemoryRouter as Router } from 'react-router-dom';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: '/redirect',
}),
}));
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
@@ -23,12 +16,8 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
});
render(
<Router history={history}>
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
<CoursewareRedirectLandingPage />
</Router>,
);
@@ -37,12 +26,8 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
});
render(
<Router history={history}>
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
<CoursewareRedirectLandingPage />
</Router>,
);

View File

@@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import {
generatePath, useParams, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants';
const RedirectPage = ({
pattern, mode,
}) => {
const { courseId } = useParams();
const location = useLocation();
const { consentPath } = queryString.parse(location?.search);
const BASE_URL = getConfig().LMS_BASE_URL;
switch (mode) {
case REDIRECT_MODES.DASHBOARD_REDIRECT:
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
break;
case REDIRECT_MODES.CONSENT_REDIRECT:
global.location.assign(`${BASE_URL}${consentPath}`);
break;
case REDIRECT_MODES.HOME_REDIRECT:
global.location.assign(generatePath(pattern, { courseId }));
break;
default:
global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`);
}
return null;
};
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import RedirectPage from './RedirectPage';
import { REDIRECT_MODES } from '../constants';
const BASE_URL = getConfig().LMS_BASE_URL;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId: 'course-id-123',
}),
useLocation: () => ({
search: '?consentPath=/some-path',
}),
}));
describe('RedirectPage component', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { assign: jest.fn() },
});
jest.clearAllMocks();
});
it('should handle DASHBOARD_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.DASHBOARD_REDIRECT} pattern="/dashboard" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`);
});
it('should handle CONSENT_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`);
});
it('should handle HOME_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.HOME_REDIRECT} pattern="/course/:courseId/home" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home');
});
it('should handle the default case correctly', () => {
render(
<MemoryRouter>
<RedirectPage pattern="/default/:courseId" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`);
});
});

View File

@@ -10,11 +10,11 @@ import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import Chat from './chat/Chat';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
import SidebarTriggers from './sidebar/SidebarTriggers';
import ChatTrigger from './lti-modal/ChatTrigger';
import { useModel } from '../../generic/model-store';
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
@@ -93,11 +93,12 @@ const Course = ({
/>
{shouldDisplayTriggers && (
<>
<ChatTrigger
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
launchUrl={course.learningAssistantLaunchUrl}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
/>
<SidebarTriggers />
</>

View File

@@ -15,6 +15,10 @@ import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
@@ -65,11 +69,11 @@ describe('Course', () => {
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
await render(<Course {...mockData} />, { store: testStore });
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
};
it('loads learning sequence', async () => {
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
@@ -102,7 +106,7 @@ describe('Course', () => {
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
@@ -120,7 +124,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
@@ -128,7 +132,7 @@ describe('Course', () => {
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
@@ -181,7 +185,7 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
@@ -192,7 +196,7 @@ describe('Course', () => {
it('handles reload persisting notification tray status', async () => {
sessionStorage.clear();
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
@@ -216,7 +220,7 @@ describe('Course', () => {
// set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
@@ -244,7 +248,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -276,12 +280,12 @@ describe('Course', () => {
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
@@ -305,7 +309,7 @@ describe('Course', () => {
courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
});
@@ -339,7 +343,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
@@ -373,7 +377,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});

View File

@@ -159,6 +159,7 @@ const CourseBreadcrumbs = ({
<Link
className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`}
replace
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage

View File

@@ -26,6 +26,12 @@ jest.mock('react-redux', () => ({
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest.fn().mockImplementation(({ to, children }) => (
<a href={to}>{children}</a>
)),
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { history } from '@edx/frontend-platform';
import { Dropdown } from '@edx/paragon';
import {
sendTrackingLogEvent,
sendTrackEvent,
} from '@edx/frontend-platform/analytics';
import { useNavigate } from 'react-router-dom';
const JumpNavMenuItem = ({
title,
@@ -17,6 +17,8 @@ const JumpNavMenuItem = ({
isDefault,
onClick,
}) => {
const navigate = useNavigate();
function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = {
@@ -38,7 +40,7 @@ const JumpNavMenuItem = ({
function handleClick(e) {
const url = destinationUrl();
logEvent(url);
history.push(url);
navigate(url);
if (onClick) { onClick(e); }
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import JumpNavMenuItem from './JumpNavMenuItem';
import { fireEvent } from '../../setupTest';
@@ -26,9 +27,11 @@ const mockData = {
};
describe('JumpNavMenuItem', () => {
render(
<JumpNavMenuItem
{...mockData}
/>,
<BrowserRouter>
<JumpNavMenuItem
{...mockData}
/>
</BrowserRouter>,
);
it('renders menu Item as expected with button and Text and handles clicks', () => {
expect(screen.queryAllByRole('button')).toHaveLength(1);

View File

@@ -0,0 +1,76 @@
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
}) => {
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
];
const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
];
const isEnrolled = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
);
const shouldDisplayChat = (
enabled
&& (isEnrolled || isStaff) // display only to enrolled or staff
);
// TODO: Remove this Segment alert. This has been added purely to diagnose whether
// usage issues are as a result of the Xpert toggle button not appearing.
if (shouldDisplayChat) {
sendTrackEvent('edx.ui.lms.learning_assistant.render', {
course_id: courseId,
});
}
return (
<>
{/* Use a portal to ensure that component overlay does not compete with learning MFE styles. */}
{shouldDisplayChat && (createPortal(
<Xpert courseId={courseId} contentToolsEnabled={contentToolsEnabled} />,
document.body,
))}
</>
);
};
Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
};
Chat.defaultProps = {
enrollmentMode: null,
};
export default injectIntl(Chat);

View File

@@ -0,0 +1,157 @@
import { BrowserRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import React from 'react';
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
import { initializeMockApp, render, screen } from '../../../setupTest';
import Chat from './Chat';
jest.mock('@edx/frontend-platform/analytics');
initializeMockApp();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let testCases = [];
let enabledTestCases = [];
let disabledTestCases = [];
const enabledModes = [
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
'paid-executive-education', 'paid-bootcamp', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz'];
describe('Chat', () => {
// Generate test cases.
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
testCases = enabledTestCases.concat(disabledTestCases);
testCases.forEach(test => {
it(
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
// Generate test cases.
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
testCases.forEach(test => {
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
});
});
// Generate the map function used for generating test cases by currying the map function.
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
// defining two separate map functions that differ in only one case, curry the function.
const generateMapFunction = (areEnabledModes) => (
(mode) => (
[
{
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
},
{
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
},
{
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
},
{
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
},
]
)
);
// Generate test cases.
enabledTestCases = enabledModes.map(generateMapFunction(true));
disabledTestCases = disabledModes.map(generateMapFunction(false));
testCases = enabledTestCases.concat(disabledTestCases);
testCases = testCases.flat();
testCases.forEach(test => {
it(
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
and ${test.enrollmentMode} enrollment mode`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
});

View File

@@ -0,0 +1 @@
export { default } from './Chat';

View File

@@ -1,23 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import Calculator from './calculator';
import NotesVisibility from './notes-visibility';
const ContentTools = ({
course,
}) => (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
);
}) => {
const {
sidebarIsOpen,
} = useSelector(state => state.learningAssistant);
return (
!sidebarIsOpen && (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
)
);
};
ContentTools.propTypes = {
course: PropTypes.shape({

View File

@@ -25,7 +25,7 @@ class NotesVisibility extends Component {
}
handleClick = () => {
const data = { visibility: this.state.visible };
const data = { visibility: !this.state.visible };
getAuthenticatedHttpClient().put(
this.visibilityUrl,
data,

View File

@@ -85,7 +85,7 @@ describe('Notes Visibility', () => {
expect(axiosMock.history.put).toHaveLength(1);
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${!mockData.course.notes.visible}}`);
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
});

View File

@@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { useSelector } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress';
@@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => {
} else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />);
} else {
return (<Redirect to={`/course/${courseId}`} />);
return (<Navigate to={`/course/${courseId}`} replace />);
}
return (

View File

@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
async function fetchAndRender(component) {
await executeThunk(fetchCourse(courseId), store.dispatch);
render(component, { store });
render(component, { store, wrapWithRouter: true });
}
beforeEach(() => {

View File

@@ -1,133 +0,0 @@
import React, { useState } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
Icon,
useToggle,
OverlayTrigger,
Popover,
} from '@edx/paragon';
import { ChatBubbleOutline } from '@edx/paragon/icons';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import messages from './messages';
const ChatTrigger = ({
intl,
enrollmentMode,
isStaff,
launchUrl,
courseId,
}) => {
const [isOpen, open, close] = useToggle(false);
const [hasOpenedChat, setHasOpenedChat] = useState(false);
const { userId } = getAuthenticatedUser();
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
];
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& VERIFIED_MODES.some(mode => mode === enrollmentMode)
);
const shouldDisplayChat = (
launchUrl
&& (isVerifiedEnrollmentMode || isStaff) // display only to non-audit or staff
);
const handleOpen = () => {
if (!hasOpenedChat) {
setHasOpenedChat(true);
}
open();
sendTrackEvent('edx.ui.lms.lti_modal.opened', {
course_id: courseId,
user_id: userId,
is_staff: isStaff,
});
};
return (
<>
{shouldDisplayChat && (
<div
className={classNames('mt-3', 'd-flex', 'ml-auto')}
>
<OverlayTrigger
trigger="click"
key="top"
show={!hasOpenedChat}
overlay={(
<Popover id="popover-chat-information">
<Popover.Title as="h3">{intl.formatMessage(messages.popoverTitle)}</Popover.Title>
<Popover.Content>
{intl.formatMessage(messages.popoverContent)}
</Popover.Content>
</Popover>
)}
>
<button
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex"
type="button"
onClick={handleOpen}
aria-label={intl.formatMessage(messages.openChatModalTrigger)}
>
<div className="icon-container d-flex position-relative align-items-center">
<Icon src={ChatBubbleOutline} className="m-0 m-auto" />
</div>
</button>
</OverlayTrigger>
<ModalDialog
onClose={close}
isOpen={isOpen}
title={intl.formatMessage(messages.modalTitle)}
size="xl"
hasCloseButton
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.modalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<iframe
src={launchUrl}
allowFullScreen
style={{
width: '100%',
height: '60vh',
}}
title={intl.formatMessage(messages.modalTitle)}
/>
</ModalDialog.Body>
</ModalDialog>
</div>
)}
</>
);
};
ChatTrigger.propTypes = {
intl: intlShape.isRequired,
isStaff: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
launchUrl: PropTypes.string,
courseId: PropTypes.string.isRequired,
};
ChatTrigger.defaultProps = {
launchUrl: null,
enrollmentMode: null,
};
export default injectIntl(ChatTrigger);

View File

@@ -1,88 +0,0 @@
import { render } from '@testing-library/react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import ChatTrigger from './ChatTrigger';
import { act, fireEvent, screen } from '../../../setupTest';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ userId: 1 })),
}));
jest.mock('@edx/frontend-platform/analytics');
describe('ChatTrigger', () => {
it('handles click to open/close chat modal', async () => {
sendTrackEvent.mockClear();
render(
<IntlProvider>
<BrowserRouter>
<ChatTrigger
enrollmentMode={null}
isStaff
launchUrl="https://testurl.org"
courseId="course-edX"
/>
</BrowserRouter>,
</IntlProvider>,
);
const chatTrigger = screen.getByRole('button', { name: /Show chat modal/i });
expect(chatTrigger).toBeInTheDocument();
expect(screen.queryByText('Need help understanding course content?')).toBeInTheDocument();
await act(async () => {
fireEvent.click(chatTrigger);
});
const modalCloseButton = screen.getByRole('button', { name: /Close/i });
await expect(modalCloseButton).toBeInTheDocument();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.lti_modal.opened', {
course_id: 'course-edX',
user_id: 1,
is_staff: true,
});
await act(async () => {
fireEvent.click(modalCloseButton);
});
await expect(modalCloseButton).not.toBeInTheDocument();
expect(screen.queryByText('Need help understanding course content?')).not.toBeInTheDocument();
});
const testCases = [
{ enrollmentMode: null, isVisible: false },
{ enrollmentMode: undefined, isVisible: false },
{ enrollmentMode: 'audit', isVisible: false },
{ enrollmentMode: 'xyz', isVisible: false },
{ enrollmentMode: 'professional', isVisible: true },
{ enrollmentMode: 'verified', isVisible: true },
{ enrollmentMode: 'no-id-professional', isVisible: true },
{ enrollmentMode: 'credit', isVisible: true },
{ enrollmentMode: 'masters', isVisible: true },
{ enrollmentMode: 'executive-education', isVisible: true },
];
testCases.forEach(test => {
it(`does chat to be visible based on enrollment mode of ${test.enrollmentMode}`, async () => {
render(
<IntlProvider>
<BrowserRouter>
<ChatTrigger
enrollmentMode={test.enrollmentMode}
isStaff={false}
launchUrl="https://testurl.org"
/>
</BrowserRouter>,
</IntlProvider>,
);
const chatTrigger = screen.queryByRole('button', { name: /Show chat modal/i });
if (test.isVisible) {
expect(chatTrigger).toBeInTheDocument();
} else {
expect(chatTrigger).not.toBeInTheDocument();
}
});
});
});

View File

@@ -1 +0,0 @@
export { default } from './ChatTrigger';

View File

@@ -1,26 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
popoverTitle: {
id: 'popover.title',
defaultMessage: 'Need help understanding course content?',
description: 'Title for popover alerting user of chat modal',
},
popoverContent: {
id: 'popover.content',
defaultMessage: 'Click here for your Xpert Learning Assistant.',
description: 'Content of the popover message',
},
openChatModalTrigger: {
id: 'chat.model.trigger',
defaultMessage: 'Show chat modal',
description: 'Alt text for button that opens the chat modal',
},
modalTitle: {
id: 'chat.model.title',
defaultMessage: 'Xpert Learning Assistant',
description: 'Title for chat modal header',
},
});
export default messages;

View File

@@ -10,7 +10,6 @@ import {
} from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -139,9 +138,6 @@ const Sequence = ({
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const goToCourseExitPage = () => {
history.push(`/course/${courseId}/course-end`);
};
const defaultContent = (
<div className="sequence-container d-inline-flex flex-row">
@@ -150,7 +146,7 @@ const Sequence = ({
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
nextSequenceHandler={() => {
nextHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
@@ -158,11 +154,10 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousSequenceHandler={() => {
previousHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
@@ -186,7 +181,6 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
)}
</div>

View File

@@ -11,6 +11,10 @@ import Sequence from './Sequence';
import { fetchSequenceFailure } from '../../data/slice';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
describe('Sequence', () => {
let mockData;
@@ -42,10 +46,14 @@ describe('Sequence', () => {
it('renders correctly without data', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
render(
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders correctly for gated content', async () => {
@@ -70,12 +78,14 @@ describe('Sequence', () => {
}, false);
const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(5);
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(3);
// `Active` and `Next` buttons.
expect(screen.getAllByRole('link').length).toEqual(2);
expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container');
@@ -101,7 +111,7 @@ describe('Sequence', () => {
}, false);
render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
await waitFor(() => {
@@ -112,26 +122,30 @@ describe('Sequence', () => {
// No normal content or navigation should be rendered. Just the above alert.
expect(screen.queryAllByRole('button').length).toEqual(0);
expect(screen.queryAllByRole('link').length).toEqual(1);
});
it('displays error message on sequence load failure', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
render(<Sequence {...mockData} />, { store: testStore });
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
});
it('handles loading unit', async () => {
render(<Sequence {...mockData} />);
render(<Sequence {...mockData} />, { wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// Renders navigation buttons plus one button for each unit.
expect(screen.getAllByRole('button')).toHaveLength(4 + unitBlocks.length);
// `Previous`, `Bookmark` and `Close Tray` buttons
expect(screen.getAllByRole('button')).toHaveLength(3);
// Renders `Next` button plus one button for each unit.
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// At this point there will be 2 `Previous` and 2 `Next` buttons.
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
});
describe('sequence and unit navigation buttons', () => {
@@ -160,10 +174,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[1].id,
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
fireEvent.click(sequencePreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -176,7 +190,7 @@ describe('Sequence', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
.filter(button => button !== sequencePreviousButton)[0];
fireEvent.click(unitPreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
@@ -196,10 +210,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
fireEvent.click(sequenceNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
@@ -211,7 +225,7 @@ describe('Sequence', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
.filter(button => button !== sequenceNextButton)[0];
fireEvent.click(unitNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
@@ -234,14 +248,14 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
// Therefore the next unit will still be `the initial one + 1`.
@@ -258,7 +272,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -277,7 +291,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -319,15 +333,15 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: innerTestStore });
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
@@ -367,10 +381,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
current_tab: currentTabNumber,
@@ -394,13 +408,13 @@ describe('Sequence', () => {
describe('notification feature', () => {
it('renders notification tray in sequence', async () => {
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
expect(await screen.findByText('Notifications')).toBeInTheDocument();
});
it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn();
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
@@ -408,7 +422,7 @@ describe('Sequence', () => {
it('does not render notification tray in sequence by default if in responsive view', async () => {
global.innerWidth = breakpoints.medium.maxWidth;
const { container } = render(<Sequence {...mockData} />);
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
expect(container).not.toHaveClass('notification-tray-container');
});

View File

@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
});
it('displays loading message', () => {
render(<SequenceContent {...mockData} />);
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays messages for the locked content', async () => {
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
const { container } = render(<SequenceContent {...mockData} gated />);
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
expect(await screen.findByText('Content Locked')).toBeInTheDocument();
@@ -38,7 +38,7 @@ describe('Sequence Content', () => {
});
it('displays message for no content', () => {
render(<SequenceContent {...mockData} unitId={null} />);
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
});
});

View File

@@ -74,7 +74,7 @@ const ContentIFrame = ({
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
</div>
)}
{modalOptions.open && (
{modalOptions.isOpen && (
<Modal
body={modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
@@ -84,7 +84,7 @@ const ContentIFrame = ({
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: '100vh' }}
style={{ width: '100%', height: modalOptions.height }}
/>
)}
dialogClassName="modal-lti"

View File

@@ -29,16 +29,17 @@ const iframeBehavior = {
const modalOptions = {
closed: {
open: false,
isOpen: false,
},
withBody: {
body: 'test-body',
open: true,
isOpen: true,
},
withUrl: {
open: true,
isOpen: true,
title: 'test-modal-title',
url: 'test-modal-url',
height: 'test-height',
},
};
@@ -83,7 +84,7 @@ describe('ContentIFrame Component', () => {
});
describe('output', () => {
let component;
describe('shouldShowContent', () => {
describe('if shouldShowContent', () => {
describe('if not hasLoaded', () => {
it('displays errorPage if showError', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
@@ -121,7 +122,7 @@ describe('ContentIFrame Component', () => {
});
});
});
describe('not shouldShowContent', () => {
describe('if not shouldShowContent', () => {
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
@@ -129,13 +130,13 @@ describe('ContentIFrame Component', () => {
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
});
});
it('does not display modal if modalOptions returns open: false', () => {
it('does not display modal if modalOptions returns isOpen: false', () => {
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(Modal).length).toEqual(0);
});
describe('if modalOptions.open', () => {
describe('if modalOptions.isOpen', () => {
const testModalOpenAndHandleClose = () => {
test('Modal component is open, with handleModalClose from hook', () => {
test('Modal component isOpen, with handleModalClose from hook', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
});
};
@@ -164,7 +165,7 @@ describe('ContentIFrame Component', () => {
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: '100vh' }}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
);
});

View File

@@ -16,7 +16,7 @@ const useExamAccess = ({
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam());
React.useEffect(() => {
if (isExam()) {
return fetchExamAccess()
fetchExamAccess()
.finally(() => {
const examAccess = getExamAccess();
setAccessToken(examAccess);
@@ -26,7 +26,6 @@ const useExamAccess = ({
logError(error);
});
}
return undefined;
}, [id]);
return {

View File

@@ -4,6 +4,7 @@ import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
import { isEqual } from 'lodash';
import { waitFor } from '../../../../../setupTest';
import useExamAccess, { stateKeys } from './useExamAccess';
const getEffect = (prereqs) => {
@@ -55,18 +56,19 @@ describe('useExamAccess hook', () => {
state.expectInitializedWith(stateKeys.blockAccess, true);
});
describe('effects - on id change', () => {
let cb;
let useEffectCb;
beforeEach(() => {
useExamAccess({ id });
cb = getEffect([id], React);
useEffectCb = getEffect([id], React);
});
it('does not call fetchExamAccess if not an exam', () => {
cb();
useEffectCb();
expect(fetchExamAccess).not.toHaveBeenCalled();
});
it('fetches and sets exam access if isExam', async () => {
isExam.mockReturnValueOnce(true);
await cb();
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
state.expectSetStateCalledWith(stateKeys.accessToken, testAccessToken);
state.expectSetStateCalledWith(stateKeys.blockAccess, false);
});
@@ -74,7 +76,8 @@ describe('useExamAccess hook', () => {
it('logs error if fetchExamAccess fails', async () => {
isExam.mockReturnValueOnce(true);
fetchExamAccess.mockReturnValueOnce(Promise.reject(testError));
await cb();
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
expect(logError).toHaveBeenCalledWith(testError);
});
});

View File

@@ -5,28 +5,32 @@ import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '../../../../../generic/hooks';
export const stateKeys = StrictDict({
modalOptions: 'modalOptions',
isOpen: 'isOpen',
options: 'options',
});
export const DEFAULT_HEIGHT = '100vh';
const useModalIFrameBehavior = () => {
const [modalOptions, setModalOptions] = useKeyedState(stateKeys.modalOptions, ({ open: false }));
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT });
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === 'plugin.modal') {
payload.open = true;
setModalOptions(payload);
setOptions((current) => ({ ...current, ...payload }));
setIsOpen(true);
}
}, []);
useEventListener('message', receiveMessage);
const handleModalClose = () => {
setModalOptions({ open: false });
setIsOpen(false);
};
return {
handleModalClose,
modalOptions,
modalOptions: { isOpen, ...options },
};
};

View File

@@ -2,7 +2,7 @@ import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameBehavior, { stateKeys } from './useModalIFrameData';
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
jest.mock('react', () => ({
...jest.requireActual('react'),
@@ -20,31 +20,49 @@ describe('useModalIFrameBehavior', () => {
state.mock();
});
describe('behavior', () => {
it('initializes modalOptions to closed', () => {
it('initializes isOpen to false', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.modalOptions, { open: false });
state.expectInitializedWith(stateKeys.isOpen, false);
});
it('initializes options with default height', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
});
describe('eventListener', () => {
it('consumes modal events and opens sets modal options with open: true', () => {
const oldOptions = { some: 'old', options: 'yeah' };
state.mockVals({
[stateKeys.isOpen]: false,
[stateKeys.options]: oldOptions,
});
useModalIFrameBehavior();
expect(useEventListener).toHaveBeenCalled();
const { cb, prereqs } = useEventListener.mock.calls[0][1];
expect(prereqs).toEqual([]);
const payload = { test: 'values' };
cb({ data: { type: messageTypes.modal, payload } });
expect(state.setState.modalOptions).toHaveBeenCalledWith({ ...payload, open: true });
expect(state.setState.isOpen).toHaveBeenCalledWith(true);
expect(state.setState.options).toHaveBeenCalled();
const [[setOptionsCb]] = state.setState.options.mock.calls;
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
});
});
});
describe('output', () => {
test('handleModalClose sets modal options to closed', () => {
useModalIFrameBehavior().handleModalClose();
state.expectSetStateCalledWith(stateKeys.modalOptions, { open: false });
state.expectSetStateCalledWith(stateKeys.isOpen, false);
});
it('forwards modalOptions from state value', () => {
it('forwards modalOptions from state values', () => {
const modalOptions = { test: 'options' };
state.mockVal(stateKeys.modalOptions, modalOptions);
expect(useModalIFrameBehavior().modalOptions).toEqual(modalOptions);
state.mockVals({
[stateKeys.options]: modalOptions,
[stateKeys.isOpen]: true,
});
expect(useModalIFrameBehavior().modalOptions).toEqual({
...modalOptions,
isOpen: true,
});
});
});
});

View File

@@ -1,9 +1,9 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { Button } from '@edx/paragon';
import messages from './messages';
@@ -11,8 +11,9 @@ import messages from './messages';
const ContentLock = ({
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) => {
const navigate = useNavigate();
const handleClick = useCallback(() => {
history.push(`/course/${courseId}/${prereqId}`);
navigate(`/course/${courseId}/${prereqId}`);
}, [courseId, prereqId]);
return (

View File

@@ -1,10 +1,16 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import {
render, screen, fireEvent, initializeMockApp,
} from '../../../../setupTest';
import ContentLock from './ContentLock';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Content Lock', () => {
const mockData = {
courseId: 'test-course-id',
@@ -19,7 +25,7 @@ describe('Content Lock', () => {
});
it('displays sequence title along with lock icon', () => {
const { container } = render(<ContentLock {...mockData} />);
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
const lockIcon = container.querySelector('svg');
expect(lockIcon).toHaveClass('fa-lock');
@@ -28,16 +34,15 @@ describe('Content Lock', () => {
it('displays prerequisite name', () => {
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
render(<ContentLock {...mockData} />);
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
expect(screen.getByText(prereqText)).toBeInTheDocument();
});
it('handles click', () => {
history.push = jest.fn();
render(<ContentLock {...mockData} />);
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button'));
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
});
});

View File

@@ -1,16 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { getConfig, history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ActionRow, Alert, Button } from '@edx/paragon';
import { useNavigate } from 'react-router-dom';
import { useModel } from '../../../../generic/model-store';
import { saveIntegritySignature } from '../../../data';
import messages from './messages';
const HonorCode = ({ intl, courseId }) => {
const navigate = useNavigate();
const dispatch = useDispatch();
const {
isMasquerading,
@@ -20,7 +22,7 @@ const HonorCode = ({ intl, courseId }) => {
const siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
const handleCancel = () => history.push(`/course/${courseId}/home`);
const handleCancel = () => navigate(`/course/${courseId}/home`);
const handleAgree = () => dispatch(
// If the request is made by a staff user masquerading as a specific learner,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { getConfig, history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
@@ -9,12 +9,12 @@ import {
} from '../../../../setupTest';
import HonorCode from './HonorCode';
const mockNavigate = jest.fn();
initializeMockApp();
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
history: {
push: jest.fn(),
},
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Honor Code', () => {
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
it('cancel button links to course home ', async () => {
await setupStoreState();
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
});
it('calls to save integrity_signature when agreeing', async () => {
await setupStoreState({ username: authenticatedUser.username });
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
username: authenticatedUser.username,
},
);
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
username: 'otheruser',
},
);
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { breakpoints, Button, useWindowSize } from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
@@ -26,12 +27,13 @@ const SequenceNavigation = ({
sequenceId,
className,
onNavigate,
nextSequenceHandler,
previousSequenceHandler,
goToCourseExitPage,
nextHandler,
previousHandler,
}) => {
const sequence = useModel('sequences', sequenceId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const {
courseId,
sequenceStatus,
@@ -63,27 +65,49 @@ const SequenceNavigation = ({
);
};
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return (
<Button
variant="link"
className="previous-btn"
onClick={previousHandler}
disabled={disabled}
iconBefore={prevArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
return (
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
<Button
variant="link"
className="next-btn"
onClick={nextHandler}
disabled={disabled}
iconAfter={nextArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
</Button>
);
};
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return sequenceStatus === LOADED && (
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
{renderPreviousButton()}
{renderUnitButtons()}
{renderNextButton()}
@@ -97,9 +121,8 @@ SequenceNavigation.propTypes = {
unitId: PropTypes.string,
className: PropTypes.string,
onNavigate: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
nextHandler: PropTypes.func.isRequired,
previousHandler: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {

View File

@@ -25,22 +25,21 @@ describe('Sequence Navigation', () => {
mockData = {
unitId: unitBlocks[1].id,
sequenceId: courseware.sequenceId,
previousSequenceHandler: () => {},
previousHandler: () => {},
onNavigate: () => {},
nextSequenceHandler: () => {},
goToCourseExitPage: () => {},
nextHandler: () => {},
};
});
it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
expect(container).toBeEmptyDOMElement();
});
it('renders empty div without unitId', () => {
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
expect(getByText(container, (content, element) => (
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
});
@@ -62,7 +61,7 @@ describe('Sequence Navigation', () => {
sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(),
};
render(<SequenceNavigation {...testData} />, { store: testStore });
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
fireEvent.click(unitButton);
@@ -75,27 +74,27 @@ describe('Sequence Navigation', () => {
it('renders correctly and handles unit button clicks', () => {
const onNavigate = jest.fn();
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
expect(unitButtons).toHaveLength(unitButtons.length);
unitButtons.forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
});
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
render(<SequenceNavigation {...mockData} />);
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
expect(button).toBeEnabled();
});
});
it('has the "Previous" button disabled for the first unit of the sequence', () => {
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
@@ -107,10 +106,10 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
@@ -123,11 +122,11 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
});
it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -144,22 +143,22 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
});
it('handles "Previous" and "Next" click', () => {
const previousSequenceHandler = jest.fn();
const nextSequenceHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
const previousHandler = jest.fn();
const nextHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(previousHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(nextHandler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,14 +40,17 @@ describe('Sequence Navigation Dropdown', () => {
unitBlocks.forEach((unit, index) => {
it(`marks unit ${index + 1} as active`, async () => {
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
const { container } = render(
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
{ wrapWithRouter: true },
);
const dropdownToggle = container.querySelector('.dropdown-toggle');
await act(async () => {
await fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown-menu');
// Only the current unit should be marked as active.
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
if (button.textContent === unit.display_name) {
expect(button).toHaveClass('active');
} else {
@@ -59,14 +62,17 @@ describe('Sequence Navigation Dropdown', () => {
it('handles the clicks', () => {
const onNavigate = jest.fn();
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
const { container } = render(
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
{ wrapWithRouter: true },
);
const dropdownToggle = container.querySelector('.dropdown-toggle');
act(() => {
fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown-menu');
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
unitBlocks.forEach((unit, index) => {
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);

View File

@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(<SequenceNavigationTabs {...mockData} />);
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
});
it('renders unit buttons and dropdown button', async () => {
let container = null;
await act(async () => {
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
const booyah = render(<SequenceNavigationTabs {...mockData} />);
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
container = booyah.container;
const dropdownToggle = container.querySelector('.dropdown-toggle');
@@ -60,8 +60,8 @@ describe('Sequence Navigation Tabs', () => {
await fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown');
const dropdownButtons = getAllByRole(dropdownMenu, 'button');
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
expect(dropdownButtons).toHaveLength(unitBlocks.length);
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
.toHaveClass('dropdown-toggle');
});

View File

@@ -1,6 +1,7 @@
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { connect, useSelector } from 'react-redux';
import classNames from 'classnames';
import { Button } from '@edx/paragon';
@@ -20,6 +21,8 @@ const UnitButton = ({
className,
showTitle,
}) => {
const { courseId, sequenceId } = useSelector(state => state.courseware);
const handleClick = useCallback(() => {
onClick(unitId);
}, [onClick, unitId]);
@@ -33,6 +36,8 @@ const UnitButton = ({
variant="link"
onClick={handleClick}
title={title}
as={Link}
to={`/course/${courseId}/${sequenceId}/${unitId}`}
>
<UnitIcon type={contentType} />
{showTitle && <span className="unit-title">{title}</span>}

View File

@@ -32,13 +32,13 @@ describe('Unit Button', () => {
});
it('hides title by default', () => {
render(<UnitButton {...mockData} />);
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
});
it('shows title', () => {
render(<UnitButton {...mockData} showTitle />);
expect(screen.getByRole('button')).toHaveTextContent(unit.display_name);
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
});
it('does not show completion for non-completed unit', () => {
@@ -49,7 +49,7 @@ describe('Unit Button', () => {
});
it('shows completion for completed unit', () => {
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(2);
expect(buttonIcons[1]).toHaveClass('fa-check');
@@ -70,7 +70,7 @@ describe('Unit Button', () => {
});
it('shows bookmark', () => {
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(3);
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
@@ -78,8 +78,8 @@ describe('Unit Button', () => {
it('handles the click', () => {
const onClick = jest.fn();
render(<UnitButton {...mockData} onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('link'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -20,14 +21,32 @@ const UnitNavigation = ({
unitId,
onClickPrevious,
onClickNext,
goToCourseExitPage,
}) => {
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const { courseId } = useSelector(state => state.courseware);
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={disabled}
onClick={onClickPrevious}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
@@ -35,8 +54,10 @@ const UnitNavigation = ({
<Button
variant="outline-primary"
className="next-button d-flex align-items-center justify-content-center"
onClick={buttonOnClick}
onClick={onClickNext}
disabled={disabled}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
{buttonText}
@@ -46,18 +67,9 @@ const UnitNavigation = ({
);
};
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<div className="unit-navigation d-flex">
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={isFirstUnit}
onClick={onClickPrevious}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
{renderPreviousButton()}
{renderNextButton()}
</div>
);
@@ -69,7 +81,6 @@ UnitNavigation.propTypes = {
unitId: PropTypes.string,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
};
UnitNavigation.defaultProps = {

View File

@@ -22,7 +22,6 @@ describe('Unit Navigation', () => {
sequenceId: courseware.sequenceId,
onClickPrevious: () => {},
onClickNext: () => {},
goToCourseExitPage: () => {},
};
});
@@ -33,10 +32,10 @@ describe('Unit Navigation', () => {
unitId=""
onClickPrevious={() => {}}
onClickNext={() => {}}
/>);
/>, { wrapWithRouter: true });
// Only "Previous" and "Next" buttons should be rendered.
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getAllByRole('link')).toHaveLength(2);
});
it('handles the clicks', () => {
@@ -45,32 +44,30 @@ describe('Unit Navigation', () => {
render(<UnitNavigation
{...mockData}
sequenceId=""
unitId=""
onClickPrevious={onClickPrevious}
onClickNext={onClickNext}
/>);
/>, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(onClickPrevious).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(onClickNext).toHaveBeenCalledTimes(1);
});
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
render(<UnitNavigation {...mockData} />);
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
screen.getAllByRole('button').forEach(button => {
screen.getAllByRole('link').forEach(button => {
expect(button).toBeEnabled();
});
});
it('has the "Previous" button disabled for the first unit in the sequence', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
@@ -82,10 +79,10 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
@@ -98,11 +95,11 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
});
it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -119,10 +116,10 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
});
});

View File

@@ -7,6 +7,7 @@ import { sequenceIdsSelector } from '../../../data';
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequence = useModel('sequences', currentSequenceId);
const courseId = useSelector(state => state.courseware.courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
@@ -14,12 +15,43 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
return { isFirstUnit: false, isLastUnit: false };
}
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
const isFirstSequence = sequenceIndex === 0;
const isFirstUnitInSequence = unitIndex === 0;
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
const isLastUnit = isLastSequence && isLastUnitInSequence;
return { isFirstUnit, isLastUnit };
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
let nextLink;
if (isLastUnit) {
nextLink = `/course/${courseId}/course-end`;
} else {
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
nextLink = `/course/${courseId}/${currentSequenceId}/${nextUnitId}`;
} else if (nextSequenceId) {
nextLink = `/course/${courseId}/${nextSequenceId}/first`;
}
}
let previousLink;
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
previousLink = `/course/${courseId}/${currentSequenceId}/${previousUnitId}`;
} else if (previousSequenceId) {
previousLink = `/course/${courseId}/${previousSequenceId}/last`;
}
return {
isFirstUnit, isLastUnit, nextLink, previousLink,
};
}

View File

@@ -3,7 +3,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { QuestionAnswer } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import React, { useContext, useEffect } from 'react';
import React, { useContext, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useModel } from '../../../../../generic/model-store';
import { getCourseDiscussionTopics } from '../../../../data/thunks';
@@ -23,12 +23,16 @@ const DiscussionsTrigger = ({
courseId,
} = useContext(SidebarContext);
const dispatch = useDispatch();
const { tabs } = useModel('courseHomeMeta', courseId);
const topic = useModel('discussionTopics', unitId);
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
const edxProvider = useMemo(
() => tabs?.find(tab => tab.slug === 'discussion'),
[tabs],
);
useEffect(() => {
// Only fetch the topic data if the MFE is configured.
if (baseUrl) {
if (baseUrl && edxProvider) {
dispatch(getCourseDiscussionTopics(courseId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -57,5 +57,5 @@ Factory.define('courseMetadata')
related_programs: null,
user_needs_integrity_signature: false,
recommendations: null,
learning_assistant_launch_url: null,
learning_assistant_enabled: null,
});

View File

@@ -122,7 +122,7 @@ function normalizeMetadata(metadata) {
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
canAccessProctoredExams: data.can_access_proctored_exams,
learningAssistantLaunchUrl: data.learning_assistant_launch_url,
learningAssistantEnabled: data.learning_assistant_enabled,
};
}

View File

@@ -228,7 +228,7 @@ describe('Courseware Service', () => {
linkedinAddToProfileUrl: null,
relatedPrograms: null,
userNeedsIntegritySignature: false,
learningAssistantLaunchUrl: null,
learningAssistantEnabled: false,
};
setTimeout(() => {
provider.addInteraction({
@@ -334,7 +334,7 @@ describe('Courseware Service', () => {
verification_status: string('none'),
linkedin_add_to_profile_url: null,
user_needs_integrity_signature: boolean(false),
learning_assistant_launch_url: null,
learning_assistant_enabled: boolean(false),
},
},
});

22
src/courseware/utils.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
const withParamsAndNavigation = WrappedComponent => {
const WithParamsNavigationComponent = props => {
const { courseId, sequenceId, unitId } = useParams();
const navigate = useNavigate();
return (
<WrappedComponent
routeCourseId={courseId}
routeSequenceId={sequenceId}
routeUnitId={unitId}
navigate={navigate}
{...props}
/>
);
};
return WithParamsNavigationComponent;
};
export default withParamsAndNavigation;

View File

@@ -2,15 +2,16 @@
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
<div>
PageRoute: {
"computedMatch": {
"path": "/course/:courseId/home",
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
"isExact": true,
"params": {
"courseId": "course-v1:edX+DemoX+Demo_Course"
}
}
PageWrap: {
"children": [
" ",
[
" ",
[],
" "
],
" "
]
}
</div>
`;

View File

@@ -1,7 +1,15 @@
import PropTypes from 'prop-types';
import { PageRoute } from '@edx/frontend-platform/react';
import { PageWrap } from '@edx/frontend-platform/react';
import React from 'react';
import { useHistory, generatePath } from 'react-router';
import {
generatePath, useMatch, Navigate,
} from 'react-router-dom';
import { DECODE_ROUTES } from '../constants';
const ROUTES = [].concat(
...Object.values(DECODE_ROUTES).map(value => (Array.isArray(value) ? value : [value])),
);
export const decodeUrl = (encodedUrl) => {
const decodedUrl = decodeURIComponent(encodedUrl);
@@ -11,10 +19,16 @@ export const decodeUrl = (encodedUrl) => {
return decodeUrl(decodedUrl);
};
const DecodePageRoute = (props) => {
const history = useHistory();
if (props.computedMatch) {
const { url, path, params } = props.computedMatch;
const DecodePageRoute = ({ children }) => {
let computedMatch = null;
ROUTES.forEach((route) => {
const matchedRoute = useMatch(route);
if (matchedRoute) { computedMatch = matchedRoute; }
});
if (computedMatch) {
const { pathname, pattern, params } = computedMatch;
Object.keys(params).forEach((param) => {
// only decode params not the entire url.
@@ -22,28 +36,19 @@ const DecodePageRoute = (props) => {
params[param] = decodeUrl(params[param]);
});
const newUrl = generatePath(path, params);
const newUrl = generatePath(pattern.path, params);
// if the url get decoded, reroute to the decoded url
if (newUrl !== url) {
history.replace(newUrl);
if (newUrl !== pathname) {
return <Navigate to={newUrl} replace />;
}
}
return <PageRoute {...props} />;
return <PageWrap> {children} </PageWrap>;
};
DecodePageRoute.propTypes = {
computedMatch: PropTypes.shape({
url: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
params: PropTypes.any,
}),
};
DecodePageRoute.defaultProps = {
computedMatch: null,
children: PropTypes.node.isRequired,
};
export default DecodePageRoute;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { render } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router, matchPath } from 'react-router';
import {
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
} from 'react-router-dom';
import DecodePageRoute, { decodeUrl } from '.';
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -15,84 +16,90 @@ const deepEncodedCourseId = (() => {
})();
jest.mock('@edx/frontend-platform/react', () => ({
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
PageWrap: (props) => `PageWrap: ${JSON.stringify(props, null, 2)}`,
}));
jest.mock('../constants', () => ({
DECODE_ROUTES: {
MOCK_ROUTE_1: '/course/:courseId/home',
MOCK_ROUTE_2: `/course/:courseId/${encodeURIComponent('some+thing')}/:unitId`,
},
}));
const renderPage = (props) => {
const memHistory = createMemoryHistory({
initialEntries: [props?.path],
});
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
const history = {
...memHistory,
replace: jest.fn(),
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
const renderPage = (props) => {
const { container } = render(
<Router history={history}>
<DecodePageRoute computedMatch={props} />
<Router initialEntries={[props?.pathname]}>
<Routes>
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
</Routes>
</Router>,
);
return {
container,
history,
props,
};
return { container };
};
describe('DecodePageRoute', () => {
it('should not modify the url if it does not need to be decoded', () => {
const props = matchPath(`/course/${decodedCourseId}/home`, {
path: '/course/:courseId/home',
});
const { container, history } = renderPage(props);
afterEach(() => {
mockNavigate.mockClear();
});
expect(props.url).toContain(decodedCourseId);
expect(history.replace).not.toHaveBeenCalled();
it('should not modify the url if it does not need to be decoded', () => {
const props = matchPath({
path: '/course/:courseId/home',
}, `/course/${decodedCourseId}/home`);
const { container } = renderPage(props);
expect(props.pathname).toContain(decodedCourseId);
expect(mockNavigate).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('should decode the url and replace the history if necessary', () => {
const props = matchPath(`/course/${encodedCourseId}/home`, {
const props = matchPath({
path: '/course/:courseId/home',
});
const { history } = renderPage(props);
}, `/course/${encodedCourseId}/home`);
renderPage(props);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(encodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
expect(props.pathname).not.toContain(decodedCourseId);
expect(props.pathname).toContain(encodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
});
it('should decode the url multiple times if necessary', () => {
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
const props = matchPath({
path: '/course/:courseId/home',
});
const { history } = renderPage(props);
}, `/course/${deepEncodedCourseId}/home`);
renderPage(props);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(deepEncodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
expect(props.pathname).not.toContain(decodedCourseId);
expect(props.pathname).toContain(deepEncodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
});
it('should only decode the url params and not the entire url', () => {
const decodedUnitId = 'some+thing';
const encodedUnitId = encodeURIComponent(decodedUnitId);
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
const props = matchPath({
path: `/course/:courseId/${encodedUnitId}/:unitId`,
});
const { history } = renderPage(props);
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
renderPage(props);
const decodedUrls = history.replace.mock.calls[0][0].split('/');
// unitId get decoded
expect(decodedUrls.pop()).toContain(decodedUnitId);
// path remain encoded
expect(decodedUrls.pop()).toContain(encodedUnitId);
// courseId get decoded
expect(decodedUrls.pop()).toContain(decodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
});
});

View File

@@ -1,9 +1,8 @@
import React, { useEffect } from 'react';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import Footer from '@edx/frontend-component-footer';
import { useParams } from 'react-router-dom';
import { useParams, Navigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { Redirect } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert';
import { AlertList } from './user-messages';
@@ -38,7 +37,7 @@ const CourseAccessErrorPage = ({ intl }) => {
);
}
if (courseStatus === LOADED) {
return (<Redirect to={`/redirect/home/${courseId}`} />);
return <Navigate to={`/redirect/home/${courseId}`} replace />;
}
return (
<>

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import { Route } from 'react-router';
import { Routes, Route } from 'react-router-dom';
import { initializeTestStore, render, screen } from '../setupTest';
import CourseAccessErrorPage from './CourseAccessErrorPage';
const mockDispatch = jest.fn();
const mockNavigate = jest.fn();
let mockCourseStatus;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
@@ -14,6 +16,10 @@ jest.mock('react-redux', () => ({
jest.mock('./PageLoading', () => function () {
return <div data-testid="page-loading" />;
});
jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom')),
useNavigate: () => mockNavigate,
}));
describe('CourseAccessErrorPage', () => {
let courseId;
@@ -28,33 +34,36 @@ describe('CourseAccessErrorPage', () => {
it('Displays loading in start on page rendering', () => {
mockCourseStatus = 'loading';
render(
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
);
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
expect(history.location.pathname).toBe(accessDeniedUrl);
expect(window.location.pathname).toBe(accessDeniedUrl);
});
it('Redirect user to homepage if user has access', () => {
mockCourseStatus = 'loaded';
render(
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
);
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
});
it('For access denied it should render access denied page', () => {
mockCourseStatus = 'denied';
render(
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
);
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
expect(history.location.pathname).toBe(accessDeniedUrl);
expect(window.location.pathname).toBe(accessDeniedUrl);
});
});

View File

@@ -1,4 +1,4 @@
import { Redirect, useLocation } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -16,10 +16,10 @@ const PathFixesProvider = ({ children }) => {
// We only check for spaces. That's not the only kind of character that is escaped in URLs, but it would always be
// present for our cases, and I believe it's the only one we use normally.
if (location.pathname.includes(' ')) {
if (location.pathname.includes(' ') || location.pathname.includes('%20')) {
const newLocation = {
...location,
pathname: location.pathname.replaceAll(' ', '+'),
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
};
sendTrackEvent('edx.ui.lms.path_fixed', {
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
search: location.search,
});
return (<Redirect to={newLocation} />);
return (<Navigate to={newLocation} replace />);
}
return children; // pass through

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { MemoryRouter, Route } from 'react-router-dom';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -19,16 +21,20 @@ describe('PathFixesProvider', () => {
});
function buildAndRender(path) {
const LocationComponent = () => {
testLocation = useLocation();
return null;
};
render(
<MemoryRouter initialEntries={[path]}>
<PathFixesProvider>
<Route
path="*"
render={routeProps => {
testLocation = routeProps.location;
return null;
}}
/>
<Routes>
<Route
path="*"
element={<LocationComponent />}
/>
</Routes>
</PathFixesProvider>
</MemoryRouter>,
);

View File

@@ -6,10 +6,10 @@ import {
mergeConfig,
getConfig,
} from '@edx/frontend-platform';
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
import { AppProvider, ErrorPage, PageWrap } from '@edx/frontend-platform/react';
import React from 'react';
import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { Routes, Route } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { fetchDiscussionTab, fetchLiveTab } from './course-home/data/thunks';
@@ -36,6 +36,7 @@ import PathFixesProvider from './generic/path-fixes';
import LiveTab from './course-home/live-tab/LiveTab';
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
import DecodePageRoute from './decode-page-route';
import { DECODE_ROUTES, ROUTES } from './constants';
subscribe(APP_READY, () => {
ReactDOM.render(
@@ -46,59 +47,91 @@ subscribe(APP_READY, () => {
<PathFixesProvider>
<NoticesProvider>
<UserMessagesProvider>
<Switch>
<PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
<DecodePageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
<DecodePageRoute path="/course/:courseId/home">
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/live">
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
<LiveTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/discussion/:path*">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute
path={[
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
]}
render={({ match }) => (
<TabContainer
tab="progress"
fetch={(courseId) => fetchProgressTab(courseId, match.params.targetUserId)}
slice="courseHome"
>
<ProgressTab />
</TabContainer>
<Routes>
<Route path={ROUTES.UNSUBSCRIBE} element={<PageWrap><GoalUnsubscribe /></PageWrap>} />
<Route path={ROUTES.REDIRECT} element={<PageWrap><CoursewareRedirectLandingPage /></PageWrap>} />
<Route
path={DECODE_ROUTES.ACCESS_DENIED}
element={<DecodePageRoute><CourseAccessErrorPage /></DecodePageRoute>}
/>
<Route
path={DECODE_ROUTES.HOME}
element={(
<DecodePageRoute>
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab />
</TabContainer>
</DecodePageRoute>
)}
/>
<Route
path={DECODE_ROUTES.LIVE}
element={(
<DecodePageRoute>
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
<LiveTab />
</TabContainer>
</DecodePageRoute>
)}
/>
<DecodePageRoute path="/course/:courseId/course-end">
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
<CourseExit />
</TabContainer>
</DecodePageRoute>
<DecodePageRoute
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
<Route
path={DECODE_ROUTES.DATES}
element={(
<DecodePageRoute>
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</DecodePageRoute>
)}
/>
</Switch>
<Route
path={DECODE_ROUTES.DISCUSSION}
element={(
<DecodePageRoute>
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</DecodePageRoute>
)}
/>
{DECODE_ROUTES.PROGRESS.map((route) => (
<Route
path={route}
element={(
<DecodePageRoute>
<TabContainer
tab="progress"
fetch={fetchProgressTab}
slice="courseHome"
isProgressTab
>
<ProgressTab />
</TabContainer>
</DecodePageRoute>
)}
/>
))}
<Route
path={DECODE_ROUTES.COURSE_END}
element={(
<DecodePageRoute>
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
<CourseExit />
</TabContainer>
</DecodePageRoute>
)}
/>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={(
<DecodePageRoute>
<CoursewareContainer />
</DecodePageRoute>
)}
/>
))}
</Routes>
</UserMessagesProvider>
</NoticesProvider>
</PathFixesProvider>
@@ -137,6 +170,8 @@ initialize({
EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null,
PROCTORED_EXAM_FAQ_URL: process.env.PROCTORED_EXAM_FAQ_URL || null,
PROCTORED_EXAM_RULES_URL: process.env.PROCTORED_EXAM_RULES_URL || null,
CHAT_RESPONSE_URL: process.env.CHAT_RESPONSE_URL || null,
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null,
}, 'LearnerAppConfig');
},
},

View File

@@ -4,6 +4,8 @@
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-footer/dist/footer";
@import "~@edx/frontend-component-header/dist/index";
#root {
display: flex;
@@ -82,14 +84,9 @@
flex-direction: column;
flex-grow: 1;
margin-bottom: 4rem;
// On mobile, the unit container will be responsible
// for container padding.
@media (min-width: map-get($grid-breakpoints, "sm")) {
width: 100%;
margin-right: auto;
margin-left: auto;
}
width: 100%;
margin-right: auto;
margin-left: auto;
}
.sequence {
@@ -268,11 +265,17 @@
}
.unit-container {
padding: 0 $grid-gutter-width 2rem;
padding-top: 0;
padding-bottom: 2rem;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
@media (min-width: map-get($grid-breakpoints, "sm")) {
padding-left: $grid-gutter-width;
padding-right: $grid-gutter-width;
}
@media (min-width: 830px) {
padding-left: 40px;
padding-right: 40px;
@@ -280,7 +283,18 @@
}
.unit-iframe-wrapper {
margin: 0 -20px 2rem;
margin-top: 0;
// Some XBlocks (such as xblock-drag-and-drop-v2) rely on the viewport width
// to determine their layout on mobile. This is problematic because the
// viewport width may not be the same as the width of the iframe. To fix this,
// here we compensate for the padding of the parent div with "container-xl"
// class to ensure that the viewport width is the same as the width of the
// iframe.
margin-left: -$grid-gutter-width * .5;
margin-right: -$grid-gutter-width * .5;
margin-bottom: 2rem;
@media (min-width: 830px) {
margin: 0 -40px 2rem;

View File

@@ -306,7 +306,7 @@
"verification_status": "none",
"linkedin_add_to_profile_url": null,
"user_needs_integrity_signature": false,
"learning_assistant_launch_url": null
"learning_assistant_enabled": false
},
"matchingRules": {
"$.body.access_expiration.expiration_date": {
@@ -442,7 +442,7 @@
"$.body.user_needs_integrity_signature": {
"match": "type"
},
"$.body.learning_assistant_launch_url": {
"$.body.learning_assistant_enabled": {
"match": "type"
}
}

View File

@@ -3,7 +3,7 @@
* @jest-environment jsdom
*/
import React from 'react';
import { Route, Switch } from 'react-router';
import { Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -26,6 +26,7 @@ import { buildSimpleCourseBlocks } from '../shared/data/__factories__/courseBloc
import { buildOutlineFromBlocks } from '../courseware/data/__factories__/learningSequencesOutline.factory';
import { UserMessagesProvider } from '../generic/user-messages';
import { DECODE_ROUTES } from '../constants';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -62,7 +63,7 @@ describe('Course Home Tours', () => {
<LoadedTabPage courseId={courseId} activeTabSlug="outline">
<OutlineTab />
</LoadedTabPage>,
{ store },
{ store, wrapWithRouter: true },
);
}
@@ -213,16 +214,14 @@ describe('Courseware Tour', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
<Routes>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={<CoursewareContainer />}
/>
))}
</Routes>
</UserMessagesProvider>
</AppProvider>
);
@@ -293,7 +292,7 @@ describe('Courseware Tour', () => {
});
const container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNextButton);

View File

@@ -13,6 +13,7 @@ import PropTypes from 'prop-types';
import { render as rtlRender } from '@testing-library/react';
import { configureStore } from '@reduxjs/toolkit';
import MockAdapter from 'axios-mock-adapter';
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
import AppProvider from '@edx/frontend-platform/react/AppProvider';
import { reducer as courseHomeReducer } from './course-home/data';
import { reducer as coursewareReducer } from './courseware/data/slice';
@@ -116,6 +117,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
learningAssistant: learningAssistantReducer,
},
});
if (overrideStore) {
@@ -167,13 +169,14 @@ function render(
ui,
{
store = null,
wrapWithRouter = false,
...renderOptions
} = {},
) {
const Wrapper = ({ children }) => (
// eslint-disable-next-line react/jsx-filename-extension
<IntlProvider locale="en">
<AppProvider store={store || globalStore}>
<AppProvider store={store || globalStore} wrapWithRouter={wrapWithRouter}>
<UserMessagesProvider>
{children}
</UserMessagesProvider>

View File

@@ -1,3 +1,4 @@
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
import { configureStore } from '@reduxjs/toolkit';
import { reducer as courseHomeReducer } from './course-home/data';
import { reducer as coursewareReducer } from './courseware/data/slice';
@@ -11,6 +12,7 @@ export default function initializeStore() {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
learningAssistant: learningAssistantReducer,
recommendations: recommendationsReducer,
tours: toursReducer,
},

View File

@@ -12,15 +12,21 @@ const TabContainer = (props) => {
fetch,
slice,
tab,
isProgressTab,
} = props;
const { courseId: courseIdFromUrl } = useParams();
const { courseId: courseIdFromUrl, targetUserId } = useParams();
const dispatch = useDispatch();
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetch(courseIdFromUrl));
if (isProgressTab) {
dispatch(fetch(courseIdFromUrl, targetUserId));
} else {
dispatch(fetch(courseIdFromUrl));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseIdFromUrl]);
}, [courseIdFromUrl, targetUserId]);
// The courseId from the store is the course we HAVE loaded. If the URL changes,
// we don't want the application to adjust to it until it has actually loaded the new data.
@@ -47,6 +53,11 @@ TabContainer.propTypes = {
fetch: PropTypes.func.isRequired,
slice: PropTypes.string.isRequired,
tab: PropTypes.string.isRequired,
isProgressTab: PropTypes.bool,
};
TabContainer.defaultProps = {
isProgressTab: false,
};
export default TabContainer;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import { Route } from 'react-router';
import { Route, Routes, MemoryRouter } from 'react-router-dom';
import { initializeTestStore, render, screen } from '../setupTest';
import { TabContainer } from './index';
@@ -31,13 +30,19 @@ describe('Tab Container', () => {
});
it('renders correctly', () => {
history.push(`/course/${courseId}`);
render(
<Route path="/course/:courseId">
<TabContainer {...mockData}>
children={[]}
</TabContainer>
</Route>,
<MemoryRouter initialEntries={[`/course/${courseId}`]}>
<Routes>
<Route
path="/course/:courseId"
element={(
<TabContainer {...mockData}>
children={[]}
</TabContainer>
)}
/>
</Routes>
</MemoryRouter>,
);
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -49,22 +54,25 @@ describe('Tab Container', () => {
it('Should handle passing in a targetUserId', () => {
const targetUserId = '1';
history.push(`/course/${courseId}/progress/${targetUserId}/`);
render(
<Route
path="/course/:courseId/progress/:targetUserId/"
render={({ match }) => (
<TabContainer
fetch={() => mockFetch(match.params.courseId, match.params.targetUserId)}
tab="dummy"
slice="courseHome"
>
children={[]}
</TabContainer>
)}
/>,
<MemoryRouter initialEntries={[`/course/${courseId}/progress/${targetUserId}/`]}>
<Routes>
<Route
path="/course/:courseId/progress/:targetUserId/"
element={(
<TabContainer
fetch={mockFetch}
tab="dummy"
slice="courseHome"
isProgressTab
>
children={[]}
</TabContainer>
)}
/>
</Routes>
</MemoryRouter>,
);
expect(mockFetch).toHaveBeenCalledTimes(1);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { Redirect } from 'react-router';
import { Navigate } from 'react-router-dom';
import Footer from '@edx/frontend-component-footer';
import { Toast } from '@edx/paragon';
@@ -38,60 +38,48 @@ const TabPage = ({ intl, ...props }) => {
title,
} = useModel('courseHomeMeta', courseId);
if (courseStatus === 'loading') {
return (
<>
<Header />
<PageLoading
srMessage={intl.formatMessage(messages.loading)}
/>
<Footer />
</>
);
}
if (courseStatus === 'denied') {
const redirectUrl = getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess, start);
if (redirectUrl) {
return (<Redirect to={redirectUrl} />);
return (<Navigate to={redirectUrl} replace />);
}
}
// Either a success state or a denied state that wasn't redirected above (some tabs handle denied states themselves,
// like the outline tab handling unenrolled learners)
if (courseStatus === 'loaded' || courseStatus === 'denied') {
return (
<>
<Toast
action={toastBodyText ? {
label: toastBodyText,
href: toastBodyLink,
} : null}
closeLabel={intl.formatMessage(genericMessages.close)}
onClose={() => dispatch(setCallToActionToast({ header: '', link: null, link_text: null }))}
show={!!(toastHeader)}
>
{toastHeader}
</Toast>
{metadataModel === 'courseHomeMeta' && (<LaunchCourseHomeTourButton srOnly />)}
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
<LoadedTabPage {...props} />
<Footer />
</>
);
}
// courseStatus 'failed' and any other unexpected course status.
return (
<>
<Header />
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages.failure)}
</p>
{['loaded', 'denied'].includes(courseStatus) && (
<>
<Toast
action={toastBodyText ? {
label: toastBodyText,
href: toastBodyLink,
} : null}
closeLabel={intl.formatMessage(genericMessages.close)}
onClose={() => dispatch(setCallToActionToast({ header: '', link: null, link_text: null }))}
show={!!(toastHeader)}
>
{toastHeader}
</Toast>
{metadataModel === 'courseHomeMeta' && (<LaunchCourseHomeTourButton srOnly />)}
</>
)}
<Header courseOrg={org} courseNumber={number} courseTitle={title} />
{courseStatus === 'loading' && (
<PageLoading srMessage={intl.formatMessage(messages.loading)} />
)}
{['loaded', 'denied'].includes(courseStatus) && (
<LoadedTabPage {...props} />
)}
{/* courseStatus 'failed' and any other unexpected course status. */}
{(!['loading', 'loaded', 'denied'].includes(courseStatus)) && (
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages.failure)}
</p>
)}
<Footer />
</>
);