Compare commits
2 Commits
oliv
...
bw/hackath
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f4df7452 | ||
|
|
c341eb7d22 |
1
.env
1
.env
@@ -46,4 +46,3 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
@@ -15,7 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL=''
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
@@ -46,6 +46,3 @@ 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=''
|
||||
|
||||
@@ -45,4 +45,3 @@ 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'
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
|
||||
7
.github/workflows/validate.yml
vendored
7
.github/workflows/validate.yml
vendored
@@ -9,13 +9,14 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,8 +1,6 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
18
Makefile
18
Makefile
@@ -1,7 +1,6 @@
|
||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
@@ -43,24 +42,9 @@ push_translations:
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
|
||||
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
@@ -9,12 +9,6 @@ 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'
|
||||
});
|
||||
|
||||
39111
package-lock.json
generated
39111
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -30,45 +30,44 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@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",
|
||||
"@edx/frontend-component-footer": "11.6.3",
|
||||
"@edx/frontend-component-header": "3.6.4",
|
||||
"@edx/frontend-lib-special-exams": "2.10.0",
|
||||
"@edx/frontend-platform": "4.1.0",
|
||||
"@edx/paragon": "20.28.4",
|
||||
"@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.4",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@fortawesome/react-fontawesome": "0.1.18",
|
||||
"@popperjs/core": "2.11.6",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"html-react-parser": "^3.0.15",
|
||||
"js-cookie": "3.0.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"query-string": "7.1.3",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.8",
|
||||
"reselect": "4.1.7",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "^12.9.10",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@edx/browserslist-config": "1.1.1",
|
||||
"@edx/frontend-build": "^12.4.15",
|
||||
"@edx/reactifex": "2.1.1",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
@@ -76,7 +75,7 @@
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"es-check": "6.2.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.5.0",
|
||||
"jest": "27.5.1",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.long"
|
||||
id="learning.outline.alert.end.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.start.calendar"
|
||||
id="learning.outline.alert.end.calendar"
|
||||
defaultMessage="Don’t 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. "
|
||||
/>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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',
|
||||
};
|
||||
@@ -28,7 +28,6 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
course_access_redirect: false,
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
|
||||
@@ -18,9 +18,6 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
@@ -339,9 +336,6 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
@@ -538,9 +532,6 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
|
||||
@@ -204,18 +204,12 @@ export async function getDatesTabData(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 401) {
|
||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -265,7 +259,7 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
@@ -275,12 +269,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -334,20 +322,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const requestTime = Date.now();
|
||||
let tabData;
|
||||
try {
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
} catch (error) {
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
||||
const responseTime = Date.now();
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||
|
||||
import {
|
||||
getCourseHomeCourseMetadata,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
|
||||
const {
|
||||
somethingLike: like, term, boolean, string, eachLike,
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
|
||||
@@ -28,193 +28,194 @@ const provider = new PactV3({
|
||||
describe('Course Home Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, 'Custom app config for pact tests');
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
describe('When a request to fetch tab is made', () => {
|
||||
it('returns tab data for a course_id', async () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
},
|
||||
course_id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
is_enrolled: boolean(true),
|
||||
is_self_paced: boolean(false),
|
||||
is_staff: boolean(true),
|
||||
number: string('DemoX'),
|
||||
org: string('edX'),
|
||||
original_user_is_staff: boolean(true),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
tab_id: 'courseware',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
await provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
course_id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
is_enrolled: boolean(true),
|
||||
is_self_paced: boolean(false),
|
||||
is_staff: boolean(true),
|
||||
number: string('DemoX'),
|
||||
org: string('edX'),
|
||||
original_user_is_staff: boolean(true),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
tab_id: 'courseware',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
}, 100);
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to fetch dates tab is made', () => {
|
||||
it('returns course date blocks for a course_id', async () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
complete: null,
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learner_has_access: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extra_info: null,
|
||||
first_component_block_id: '',
|
||||
}),
|
||||
has_ended: boolean(false),
|
||||
learner_is_full_access: boolean(true),
|
||||
user_timezone: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
datesBannerInfo: {
|
||||
missedDeadlines: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
courseDateBlocks: [
|
||||
{
|
||||
assignmentType: null,
|
||||
await provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
complete: null,
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
dateType: 'verified-upgrade-deadline',
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learnerHasAccess: true,
|
||||
learner_has_access: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
linkText: 'Upgrade to Verified Certificate',
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
const response = getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
extra_info: null,
|
||||
first_component_block_id: '',
|
||||
}),
|
||||
has_ended: boolean(false),
|
||||
learner_is_full_access: boolean(true),
|
||||
user_timezone: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
datesBannerInfo: {
|
||||
missedDeadlines: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
courseDateBlocks: [
|
||||
{
|
||||
assignmentType: null,
|
||||
complete: null,
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
dateType: 'verified-upgrade-deadline',
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learnerHasAccess: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
linkText: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
|
||||
const response = await getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,18 +21,6 @@ describe('Data layer integration tests', () => {
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
const courseHomeAccessDeniedMetadata = Factory.build(
|
||||
'courseHomeMetadata',
|
||||
{
|
||||
id: courseId,
|
||||
course_access: {
|
||||
has_access: false,
|
||||
error_code: 'bad codes',
|
||||
additional_context_user_message: 'your Codes Are BAD',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -67,40 +55,16 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
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),
|
||||
}),
|
||||
});
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 401 || errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test fetchOutlineTab', () => {
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(outlineUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
@@ -111,6 +75,8 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
||||
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
@@ -118,31 +84,8 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
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),
|
||||
}),
|
||||
});
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test fetchProgressTab', () => {
|
||||
@@ -170,14 +113,7 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
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),
|
||||
}),
|
||||
});
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
@@ -193,19 +129,6 @@ describe('Data layer integration tests', () => {
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.targetUserId).toEqual(2);
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}`;
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('denied');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Route } from 'react-router';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
@@ -32,16 +32,11 @@ describe('DatesTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/dates"
|
||||
element={(
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -2,20 +2,21 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams, generatePath, useNavigate } from 'react-router-dom';
|
||||
import { generatePath, useHistory } from 'react-router';
|
||||
import { useParams } 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 navigate = useNavigate();
|
||||
const history = useHistory();
|
||||
|
||||
const [, iFrameHeight] = useIFrameHeight();
|
||||
useIFramePluginEvents({
|
||||
'discussions.navigate': (payload) => {
|
||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||
navigate(`${basePath}/${payload.path}`);
|
||||
history.push(`${basePath}/${payload.path}`);
|
||||
},
|
||||
});
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
||||
|
||||
@@ -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, Routes } from 'react-router-dom';
|
||||
import { Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
import {
|
||||
@@ -30,16 +30,11 @@ describe('DiscussionTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/discussion"
|
||||
element={(
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
<Route path="/course/:courseId/discussion">
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
import { Route } from 'react-router';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getConfig, history } 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';
|
||||
@@ -26,16 +24,13 @@ describe('GoalUnsubscribe', () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
|
||||
<Routes>
|
||||
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
|
||||
});
|
||||
|
||||
it('starts with a spinner', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation } 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';
|
||||
@@ -66,7 +67,6 @@ const OutlineTab = ({ intl }) => {
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
@@ -115,10 +115,8 @@ 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');
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${currentParams.toString()}`,
|
||||
replace: true,
|
||||
history.replace({
|
||||
search: currentParams.toString(),
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -16,46 +17,45 @@ 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, navigate) => {
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
||||
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) {
|
||||
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
} else if (firstSequenceId) {
|
||||
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
||||
navigate(`/course/${courseId}/${unitId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}/${unitId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
|
||||
// If the section is non-empty, redirect to its first sequence.
|
||||
if (section.sequenceIds && section.sequenceIds[0]) {
|
||||
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
|
||||
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
|
||||
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
||||
} else {
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkUnitToSequenceUnitRedirect = memoize(
|
||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
|
||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
|
||||
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,62 +64,60 @@ const checkUnitToSequenceUnitRedirect = memoize(
|
||||
getSequenceForUnitDeprecated(courseId, unitId).then(
|
||||
parentId => {
|
||||
if (parentId) {
|
||||
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
|
||||
} else {
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}`);
|
||||
}
|
||||
},
|
||||
() => { // error case
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}`);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Invalid sequence that isn't a unit either. Redirect up to main course.
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
||||
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.
|
||||
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
|
||||
(courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||
return;
|
||||
}
|
||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUnits = sequence.unitIds?.length > 0;
|
||||
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 {
|
||||
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
|
||||
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 });
|
||||
}
|
||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (unitId === 'last') {
|
||||
if (hasUnits) {
|
||||
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
|
||||
} else {
|
||||
// No units... go to general sequence page
|
||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class CoursewareContainer extends Component {
|
||||
checkSaveSequencePosition = memoize((unitId) => {
|
||||
@@ -147,8 +145,12 @@ class CoursewareContainer extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
routeCourseId,
|
||||
routeSequenceId,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
@@ -165,10 +167,13 @@ class CoursewareContainer extends Component {
|
||||
sequence,
|
||||
firstSequenceId,
|
||||
sectionViaSequenceId,
|
||||
routeCourseId,
|
||||
routeSequenceId,
|
||||
routeUnitId,
|
||||
navigate,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
@@ -197,7 +202,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, navigate);
|
||||
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
|
||||
|
||||
// Check section-unit to unit redirect:
|
||||
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
|
||||
@@ -210,54 +215,60 @@ 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, navigate);
|
||||
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
|
||||
|
||||
// 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, navigate);
|
||||
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
|
||||
|
||||
// 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, navigate
|
||||
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
|
||||
));
|
||||
|
||||
// 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, navigate);
|
||||
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
|
||||
|
||||
// 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, navigate);
|
||||
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
|
||||
}
|
||||
|
||||
handleUnitNavigationClick = () => {
|
||||
handleUnitNavigationClick = (nextUnitId) => {
|
||||
const {
|
||||
courseId,
|
||||
sequenceId,
|
||||
routeUnitId,
|
||||
courseId, sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: 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);
|
||||
@@ -265,14 +276,23 @@ class CoursewareContainer extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
handlePreviousSequenceClick = () => {};
|
||||
handlePreviousSequenceClick = () => {
|
||||
const { previousSequence, courseId } = this.props;
|
||||
if (previousSequence !== null) {
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/last`);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
courseStatus,
|
||||
courseId,
|
||||
sequenceId,
|
||||
routeUnitId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -315,9 +335,13 @@ const courseShape = PropTypes.shape({
|
||||
});
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
routeCourseId: PropTypes.string.isRequired,
|
||||
routeSequenceId: PropTypes.string,
|
||||
routeUnitId: PropTypes.string,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
firstSequenceId: PropTypes.string,
|
||||
@@ -333,14 +357,11 @@ 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,
|
||||
@@ -455,4 +476,4 @@ export default connect(mapStateToProps, {
|
||||
saveSequencePosition,
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
})(withParamsAndNavigation(CoursewareContainer));
|
||||
})(CoursewareContainer);
|
||||
|
||||
@@ -5,16 +5,13 @@ 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 {
|
||||
BrowserRouter, MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
import { Route, Switch } from 'react-router';
|
||||
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 { initializeMockApp } from '../setupTest';
|
||||
|
||||
import CoursewareContainer from './CoursewareContainer';
|
||||
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
||||
@@ -83,16 +80,18 @@ describe('CoursewareContainer', () => {
|
||||
store = initializeStore();
|
||||
|
||||
component = (
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Routes>
|
||||
{DECODE_ROUTES.COURSEWARE.map((route) => (
|
||||
<Route
|
||||
path={route}
|
||||
element={<CoursewareContainer />}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
</Switch>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -152,7 +151,7 @@ describe('CoursewareContainer', () => {
|
||||
}
|
||||
|
||||
async function loadContainer() {
|
||||
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
const { container } = render(component);
|
||||
// Wait for the page spinner to be removed, such that we can wait for our main
|
||||
// content to load before making any assertions.
|
||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||
@@ -161,7 +160,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should initialize to show a spinner', () => {
|
||||
history.push('/course/abc123');
|
||||
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
|
||||
render(component);
|
||||
|
||||
const spinner = screen.getByRole('status');
|
||||
|
||||
@@ -186,7 +185,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 a, nav.sequence-navigation button');
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
|
||||
|
||||
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
|
||||
@@ -212,7 +211,7 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -235,7 +234,7 @@ describe('CoursewareContainer', () => {
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -285,7 +284,7 @@ describe('CoursewareContainer', () => {
|
||||
describe('when the URL does not contain a unit ID', () => {
|
||||
it('should choose a unit within the section\'s first sequence', async () => {
|
||||
setUrl(sectionTree[1].id);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
||||
@@ -360,7 +359,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -379,7 +378,7 @@ describe('CoursewareContainer', () => {
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -396,7 +395,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should load the specified unit', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -412,12 +411,12 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
const container = await loadContainer();
|
||||
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
const sequenceNextButton = sequenceNavButtons[4];
|
||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||
fireEvent.click(sequenceNextButton);
|
||||
fireEvent.click(sequenceNavButtons[4]);
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
||||
});
|
||||
|
||||
@@ -1,44 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import { PageRoute } 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 = () => (
|
||||
<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 = () => {
|
||||
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..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareRedirectLandingPage;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter as Router } from 'react-router-dom';
|
||||
import { Router } from 'react-router';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { render, initializeMockApp } from '../setupTest';
|
||||
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
|
||||
|
||||
const redirectUrl = jest.fn();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useRouteMatch: () => ({
|
||||
path: '/redirect',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CoursewareRedirectLandingPage', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -16,8 +23,12 @@ describe('CoursewareRedirectLandingPage', () => {
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
|
||||
<Router history={history}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
@@ -26,8 +37,12 @@ describe('CoursewareRedirectLandingPage', () => {
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
|
||||
<Router history={history}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,69 +0,0 @@
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -92,16 +91,7 @@ const Course = ({
|
||||
unitId={unitId}
|
||||
/>
|
||||
{shouldDisplayTriggers && (
|
||||
<>
|
||||
<Chat
|
||||
enabled={course.learningAssistantEnabled}
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||
/>
|
||||
<SidebarTriggers />
|
||||
</>
|
||||
<SidebarTriggers />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,10 +15,6 @@ 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
|
||||
@@ -53,7 +49,8 @@ describe('Course', () => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
const setupDiscussionSidebar = async () => {
|
||||
const setupDiscussionSidebar = async (storageValue = false) => {
|
||||
localStorage.clear();
|
||||
const testStore = await initializeTestStore({ provider: 'openedx' });
|
||||
const state = testStore.getState();
|
||||
const { courseware: { courseId } } = state;
|
||||
@@ -68,12 +65,14 @@ describe('Course', () => {
|
||||
mockData.unitId = firstUnitId;
|
||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
||||
mockData.sequenceId = firstSequenceId;
|
||||
|
||||
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
if (storageValue !== null) {
|
||||
localStorage.setItem('showDiscussionSidebar', storageValue);
|
||||
}
|
||||
await render(<Course {...mockData} />, { store: testStore });
|
||||
};
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
render(<Course {...mockData} />);
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
@@ -106,7 +105,7 @@ describe('Course', () => {
|
||||
};
|
||||
// Set up LocalStorage for testing.
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
@@ -124,7 +123,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
@@ -132,71 +131,31 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
localStorage.setItem('showDiscussionSidebar', false);
|
||||
render(<Course {...mockData} />);
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
|
||||
});
|
||||
|
||||
it('handles click to open/close discussions sidebar', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
|
||||
|
||||
expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
});
|
||||
await expect(discussionsSideBar).toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
});
|
||||
await expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('displays discussions sidebar when unit changes', async () => {
|
||||
const testStore = await initializeTestStore();
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
|
||||
await setupDiscussionSidebar();
|
||||
|
||||
const { rerender } = render(<Course {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
rerender(null);
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
|
||||
});
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
sessionStorage.clear();
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
localStorage.setItem('showDiscussionSidebar', false);
|
||||
render(<Course {...mockData} />);
|
||||
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');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
sessionStorage.clear();
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
render(<Course {...mockData} />);
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
@@ -215,12 +174,13 @@ describe('Course', () => {
|
||||
|
||||
it('handles sessionStorage from a different course for the notification tray', async () => {
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('showDiscussionSidebar', false);
|
||||
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
|
||||
|
||||
// set sessionStorage for a different course before rendering Course
|
||||
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
|
||||
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
render(<Course {...mockData} />);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
@@ -248,7 +208,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
@@ -257,6 +217,34 @@ describe('Course', () => {
|
||||
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
[
|
||||
{ value: true, visible: true },
|
||||
{ value: false, visible: false },
|
||||
{ value: null, visible: true },
|
||||
].forEach(async ({ value, visible }) => (
|
||||
it(`discussion sidebar is ${visible ? 'shown' : 'hidden'} when localstorage value is ${value}`, async () => {
|
||||
await setupDiscussionSidebar(value);
|
||||
const element = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
|
||||
if (visible) {
|
||||
expect(element).not.toHaveClass('d-none');
|
||||
} else {
|
||||
expect(element).toHaveClass('d-none');
|
||||
}
|
||||
})));
|
||||
|
||||
[
|
||||
{ value: true, result: 'false' },
|
||||
{ value: false, result: 'true' },
|
||||
].forEach(async ({ value, result }) => (
|
||||
it(`Discussion sidebar storage value is ${!value} when sidebar is ${value ? 'closed' : 'open'}`, async () => {
|
||||
await setupDiscussionSidebar(value);
|
||||
await act(async () => {
|
||||
const button = await screen.queryByRole('button', { name: /Show discussions tray/i });
|
||||
button.click();
|
||||
});
|
||||
expect(localStorage.getItem('showDiscussionSidebar')).toBe(result);
|
||||
})));
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
const nextSequenceHandler = jest.fn();
|
||||
const previousSequenceHandler = jest.fn();
|
||||
@@ -280,12 +268,12 @@ describe('Course', () => {
|
||||
previousSequenceHandler,
|
||||
unitNavigationHandler,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
@@ -309,7 +297,7 @@ describe('Course', () => {
|
||||
courseId: courseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -343,7 +331,7 @@ describe('Course', () => {
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -377,7 +365,7 @@ describe('Course', () => {
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,80 +1,57 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useToggle, ModalPopup, Menu } from '@edx/paragon';
|
||||
import { SelectMenu } from '@edx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useModel, useModels } from '../../generic/model-store';
|
||||
import JumpNavMenuItem from './JumpNavMenuItem';
|
||||
|
||||
const CourseBreadcrumb = ({
|
||||
content,
|
||||
withSeparator,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId,
|
||||
isStaff,
|
||||
content, withSeparator, courseId, sequenceId, unitId, isStaff,
|
||||
}) => {
|
||||
const defaultContent = content.filter(
|
||||
(destination) => destination.default,
|
||||
)[0] || { id: courseId, label: '', sequences: [] };
|
||||
|
||||
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
|
||||
return (
|
||||
<>
|
||||
{withSeparator && (
|
||||
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
|
||||
)}
|
||||
|
||||
<li
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
data-testid="breadcrumb-item"
|
||||
<li style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{showRegularLink ? (
|
||||
<Link
|
||||
className="text-primary-500"
|
||||
to={
|
||||
defaultContent.sequences.length
|
||||
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
|
||||
? (
|
||||
<Link
|
||||
className="text-primary-500"
|
||||
to={defaultContent.sequences.length
|
||||
? `/course/${courseId}/${defaultContent.sequences[0].id}`
|
||||
: `/course/${courseId}/${defaultContent.id}`
|
||||
}
|
||||
>
|
||||
{defaultContent.label}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{
|
||||
// eslint-disable-next-line
|
||||
<a className="text-primary-500" onClick={open} ref={setTarget}>
|
||||
{defaultContent.label}
|
||||
</a>
|
||||
}
|
||||
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
|
||||
<Menu>
|
||||
{content.map((item) => (
|
||||
<JumpNavMenuItem
|
||||
isDefault={item.default}
|
||||
sequences={item.sequences}
|
||||
courseId={courseId}
|
||||
title={item.label}
|
||||
currentSequence={sequenceId}
|
||||
currentUnit={unitId}
|
||||
onClick={close}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</ModalPopup>
|
||||
</>
|
||||
)}
|
||||
: `/course/${courseId}/${defaultContent.id}`}
|
||||
>
|
||||
{defaultContent.label}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<SelectMenu isLink defaultMessage={defaultContent.label}>
|
||||
{content.map(item => (
|
||||
<JumpNavMenuItem
|
||||
isDefault={item.default}
|
||||
sequences={item.sequences}
|
||||
courseId={courseId}
|
||||
title={item.label}
|
||||
currentSequence={sequenceId}
|
||||
currentUnit={unitId}
|
||||
/>
|
||||
))}
|
||||
</SelectMenu>
|
||||
)}
|
||||
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
@@ -110,21 +87,14 @@ const CourseBreadcrumbs = ({
|
||||
isStaff,
|
||||
}) => {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const courseStatus = useSelector((state) => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(
|
||||
(state) => state.courseware.sequenceStatus,
|
||||
);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
const allSequencesInSections = Object.fromEntries(
|
||||
useModels('sections', course.sectionIds).map((section) => [
|
||||
section.id,
|
||||
{
|
||||
default: section.id === sectionId,
|
||||
title: section.title,
|
||||
sequences: useModels('sequences', section.sequenceIds),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
|
||||
default: section.id === sectionId,
|
||||
title: section.title,
|
||||
sequences: useModels('sequences', section.sequenceIds),
|
||||
}]));
|
||||
|
||||
const links = useMemo(() => {
|
||||
const chapters = [];
|
||||
@@ -138,7 +108,7 @@ const CourseBreadcrumbs = ({
|
||||
sequences: section.sequences,
|
||||
});
|
||||
if (section.default) {
|
||||
section.sequences.forEach((sequence) => {
|
||||
section.sequences.forEach(sequence => {
|
||||
sequentials.push({
|
||||
id: sequence.id,
|
||||
label: sequence.title,
|
||||
@@ -154,12 +124,11 @@ const CourseBreadcrumbs = ({
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
|
||||
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
|
||||
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
|
||||
<li className="list-unstyled col-auto m-0 p-0">
|
||||
<Link
|
||||
className="flex-shrink-0 text-primary"
|
||||
to={`/course/${courseId}/home`}
|
||||
replace
|
||||
>
|
||||
<FontAwesomeIcon icon={faHome} className="mr-2" />
|
||||
<FormattedMessage
|
||||
@@ -169,7 +138,7 @@ const CourseBreadcrumbs = ({
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
{links.map((content) => (
|
||||
{links.map(content => (
|
||||
<CourseBreadcrumb
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
|
||||
@@ -26,12 +26,6 @@ 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') {
|
||||
@@ -129,6 +123,6 @@ describe('CourseBreadcrumbs', () => {
|
||||
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
|
||||
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { MenuItem } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
sendTrackingLogEvent,
|
||||
sendTrackEvent,
|
||||
} from '@edx/frontend-platform/analytics';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const JumpNavMenuItem = ({
|
||||
title,
|
||||
@@ -15,10 +15,7 @@ const JumpNavMenuItem = ({
|
||||
currentUnit,
|
||||
sequences,
|
||||
isDefault,
|
||||
onClick,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function logEvent(targetUrl) {
|
||||
const eventName = 'edx.ui.lms.jump_nav.selected';
|
||||
const payload = {
|
||||
@@ -37,20 +34,19 @@ const JumpNavMenuItem = ({
|
||||
}
|
||||
return `/course/${courseId}/${sequences[0].id}`;
|
||||
}
|
||||
function handleClick(e) {
|
||||
function handleClick() {
|
||||
const url = destinationUrl();
|
||||
logEvent(url);
|
||||
navigate(url);
|
||||
if (onClick) { onClick(e); }
|
||||
history.push(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown.Item
|
||||
active={isDefault}
|
||||
onClick={e => handleClick(e)}
|
||||
<MenuItem
|
||||
defaultSelected={isDefault}
|
||||
onClick={() => handleClick()}
|
||||
>
|
||||
{title}
|
||||
</Dropdown.Item>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,10 +54,6 @@ const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
});
|
||||
|
||||
JumpNavMenuItem.defaultProps = {
|
||||
onClick: null,
|
||||
};
|
||||
|
||||
JumpNavMenuItem.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
|
||||
@@ -69,7 +61,6 @@ JumpNavMenuItem.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
currentSequence: PropTypes.string.isRequired,
|
||||
currentUnit: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default JumpNavMenuItem;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -23,15 +22,12 @@ const mockData = {
|
||||
},
|
||||
],
|
||||
isDefault: false,
|
||||
onClick: jest.fn().mockName('onClick'),
|
||||
};
|
||||
describe('JumpNavMenuItem', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<JumpNavMenuItem
|
||||
{...mockData}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
<JumpNavMenuItem
|
||||
{...mockData}
|
||||
/>,
|
||||
);
|
||||
it('renders menu Item as expected with button and Text and handles clicks', () => {
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(1);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
||||
} from '../../../setupTest';
|
||||
import { BookmarkButton } from './index';
|
||||
import { getBookmarksBaseUrl } from './data/api';
|
||||
|
||||
describe('Bookmark Button', () => {
|
||||
let axiosMock;
|
||||
@@ -32,8 +32,7 @@ describe('Bookmark Button', () => {
|
||||
mockData.unitId = nonBookmarkedUnitBlock.id;
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const bookmarkUrl = getBookmarksBaseUrl();
|
||||
|
||||
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
||||
|
||||
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
export const getBookmarksBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
|
||||
export async function createBookmark(usageId) {
|
||||
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
|
||||
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
|
||||
}
|
||||
|
||||
export async function deleteBookmark(usageId) {
|
||||
const { username } = getAuthenticatedUser();
|
||||
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
|
||||
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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);
|
||||
@@ -1,157 +0,0 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Chat';
|
||||
@@ -1,32 +1,23 @@
|
||||
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,
|
||||
}) => {
|
||||
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>
|
||||
)
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<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({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.content-tools {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 { Navigate } from 'react-router-dom';
|
||||
import { Redirect } 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 (<Navigate to={`/course/${courseId}`} replace />);
|
||||
return (<Redirect to={`/course/${courseId}`} />);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||||
render(component, { store, wrapWithRouter: true });
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
|
||||
@@ -138,6 +139,9 @@ 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">
|
||||
@@ -146,7 +150,7 @@ const Sequence = ({
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextHandler={() => {
|
||||
nextSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
@@ -154,10 +158,11 @@ const Sequence = ({
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousHandler={() => {
|
||||
previousSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||
|
||||
@@ -181,6 +186,7 @@ const Sequence = ({
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,6 @@ 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;
|
||||
@@ -46,14 +42,10 @@ 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, wrapWithRouter: true },
|
||||
);
|
||||
render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
|
||||
|
||||
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 () => {
|
||||
@@ -78,14 +70,12 @@ describe('Sequence', () => {
|
||||
}, false);
|
||||
const { container } = render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Active` and `Next` buttons.
|
||||
expect(screen.getAllByRole('link').length).toEqual(2);
|
||||
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(5);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
@@ -111,7 +101,7 @@ describe('Sequence', () => {
|
||||
}, false);
|
||||
render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -122,30 +112,26 @@ 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, wrapWithRouter: true });
|
||||
render(<Sequence {...mockData} />, { store: testStore });
|
||||
|
||||
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles loading unit', async () => {
|
||||
render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
render(<Sequence {...mockData} />);
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `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);
|
||||
// Renders navigation buttons plus one button for each unit.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4 + 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/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
@@ -174,10 +160,10 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[1].id,
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
|
||||
fireEvent.click(sequencePreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -190,7 +176,7 @@ describe('Sequence', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
|
||||
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
|
||||
.filter(button => button !== sequencePreviousButton)[0];
|
||||
fireEvent.click(unitPreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
@@ -210,10 +196,10 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
|
||||
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
|
||||
fireEvent.click(sequenceNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
|
||||
@@ -225,7 +211,7 @@ describe('Sequence', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
|
||||
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
|
||||
.filter(button => button !== sequenceNextButton)[0];
|
||||
fireEvent.click(unitNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
@@ -248,14 +234,14 @@ describe('Sequence', () => {
|
||||
previousSequenceHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
fireEvent.click(screen.getByRole('button', { 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`.
|
||||
@@ -272,7 +258,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -291,7 +277,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -333,15 +319,15 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
|
||||
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: innerTestStore });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
@@ -381,10 +367,10 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
unitNavigationHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
|
||||
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
|
||||
current_tab: currentTabNumber,
|
||||
@@ -408,13 +394,13 @@ describe('Sequence', () => {
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
|
||||
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 }} />, { wrapWithRouter: true });
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
@@ -422,7 +408,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} />, { wrapWithRouter: true });
|
||||
const { container } = render(<Sequence {...mockData} />);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
|
||||
});
|
||||
|
||||
it('displays loading message', () => {
|
||||
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
|
||||
render(<SequenceContent {...mockData} />);
|
||||
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 />, { wrapWithRouter: true });
|
||||
const { container } = render(<SequenceContent {...mockData} gated />);
|
||||
|
||||
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} />, { wrapWithRouter: true });
|
||||
render(<SequenceContent {...mockData} unitId={null} />);
|
||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
177
src/courseware/course/sequence/Unit.test.jsx
Normal file
177
src/courseware/course/sequence/Unit.test.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
initializeTestStore, loadUnit, messageEvent, render, screen, waitFor,
|
||||
} from '../../../setupTest';
|
||||
import Unit, { sendUrlHashToFrame } from './Unit';
|
||||
|
||||
describe('Unit', () => {
|
||||
let mockData;
|
||||
const courseMetadata = Factory.build(
|
||||
'courseMetadata',
|
||||
{ content_type_gating_enabled: true },
|
||||
);
|
||||
const courseMetadataNeedsSignature = Factory.build(
|
||||
'courseMetadata',
|
||||
{ user_needs_integrity_signature: true },
|
||||
);
|
||||
const unitBlocks = [
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical', graded: 'true' },
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
{
|
||||
type: 'vertical',
|
||||
contains_content_type_gated_content: true,
|
||||
bookmarked: true,
|
||||
graded: true,
|
||||
},
|
||||
{ courseId: courseMetadata.id },
|
||||
),
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical', graded: false },
|
||||
{ courseId: courseMetadata.id },
|
||||
),
|
||||
];
|
||||
const [unit, unitThatContainsGatedContent, ungradedUnit] = unitBlocks;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
mockData = {
|
||||
id: unit.id,
|
||||
courseId: courseMetadata.id,
|
||||
format: 'Homework',
|
||||
};
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<Unit {...mockData} />);
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
const renderedUnit = screen.getByTitle(unit.display_name);
|
||||
expect(renderedUnit).toHaveAttribute('height', String(0));
|
||||
expect(renderedUnit).toHaveAttribute('src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`);
|
||||
});
|
||||
|
||||
it('renders proper message for gated content', () => {
|
||||
render(<Unit {...mockData} id={unitThatContainsGatedContent.id} />);
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display HonorCode for ungraded units', async () => {
|
||||
const signatureStore = await initializeTestStore(
|
||||
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
|
||||
false,
|
||||
);
|
||||
const signatureData = {
|
||||
id: ungradedUnit.id,
|
||||
courseId: courseMetadataNeedsSignature.id,
|
||||
format: 'Homework',
|
||||
};
|
||||
render(<Unit {...signatureData} />, { store: signatureStore });
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays HonorCode for graded units if user needs integrity signature', async () => {
|
||||
const signatureStore = await initializeTestStore(
|
||||
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
|
||||
false,
|
||||
);
|
||||
const signatureData = {
|
||||
id: unit.id,
|
||||
courseId: courseMetadataNeedsSignature.id,
|
||||
format: 'Homework',
|
||||
};
|
||||
render(<Unit {...signatureData} />, { store: signatureStore });
|
||||
expect(screen.getByText('Loading honor code messaging...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles receiving MessageEvent', async () => {
|
||||
render(<Unit {...mockData} />);
|
||||
loadUnit();
|
||||
// Loading message is gone now.
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
// Iframe's height is set via message.
|
||||
expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height));
|
||||
});
|
||||
|
||||
it('calls onLoaded after receiving MessageEvent', async () => {
|
||||
const onLoaded = jest.fn();
|
||||
render(<Unit {...mockData} {...{ onLoaded }} />);
|
||||
loadUnit();
|
||||
|
||||
await waitFor(() => expect(onLoaded).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('resizes iframe on second MessageEvent, does not call onLoaded again', async () => {
|
||||
const onLoaded = jest.fn();
|
||||
// Clone message and set different height.
|
||||
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 200 } };
|
||||
render(<Unit {...mockData} {...{ onLoaded }} />);
|
||||
loadUnit();
|
||||
|
||||
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height)));
|
||||
window.postMessage(testMessageWithOtherHeight, '*');
|
||||
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithOtherHeight.payload.height)));
|
||||
expect(onLoaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scrolls page on MessagaeEvent when receiving offset', async () => {
|
||||
// Set message to constain offset data.
|
||||
const testMessageWithOffset = { offset: 1500 };
|
||||
render(<Unit {...mockData} />);
|
||||
window.postMessage(testMessageWithOffset, '*');
|
||||
|
||||
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalled()));
|
||||
expect(window.scrollY === testMessageWithOffset.offset);
|
||||
});
|
||||
|
||||
it('ignores MessageEvent with unhandled type', async () => {
|
||||
// Clone message and set different type.
|
||||
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };
|
||||
render(<Unit {...mockData} />);
|
||||
window.postMessage(testMessageWithUnhandledType, '*');
|
||||
|
||||
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
|
||||
await expect(waitFor(
|
||||
() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithUnhandledType.payload.height)),
|
||||
{ timeout: 100 },
|
||||
)).rejects.toThrowError(/Expected the element to have attribute/);
|
||||
});
|
||||
|
||||
it('scrolls to correct place onLoad', () => {
|
||||
document.body.innerHTML = "<iframe id='unit-iframe' />";
|
||||
|
||||
const mockHashCheck = jest.fn(frameVar => sendUrlHashToFrame(frameVar));
|
||||
const frame = document.getElementById('unit-iframe');
|
||||
const originalWindow = { ...window };
|
||||
const windowSpy = jest.spyOn(global, 'window', 'get');
|
||||
windowSpy.mockImplementation(() => ({
|
||||
...originalWindow,
|
||||
location: {
|
||||
...originalWindow.location,
|
||||
hash: '#test',
|
||||
},
|
||||
}));
|
||||
const messageSpy = jest.spyOn(frame.contentWindow, 'postMessage');
|
||||
messageSpy.mockImplementation(() => ({ hashName: originalWindow.location.hash }));
|
||||
mockHashCheck(frame);
|
||||
|
||||
expect(mockHashCheck).toHaveBeenCalled();
|
||||
expect(messageSpy).toHaveBeenCalled();
|
||||
|
||||
windowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls useEffect and checkForHash', () => {
|
||||
const mockHashCheck = jest.fn(() => sendUrlHashToFrame());
|
||||
const effectSpy = jest.spyOn(React, 'useEffect');
|
||||
effectSpy.mockImplementation(() => mockHashCheck());
|
||||
render(<Unit {...mockData} />);
|
||||
expect(React.useEffect).toHaveBeenCalled();
|
||||
expect(mockHashCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,11 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { StrictDict } from '@edx/react-unit-test-utils';
|
||||
import { Modal } from '@edx/paragon';
|
||||
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
import * as hooks from './hooks';
|
||||
import LocalIFrame from './LocalIFrame';
|
||||
import { renderers } from './constants';
|
||||
import hooks from './hooks';
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
@@ -18,65 +18,39 @@ import * as hooks from './hooks';
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
export const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
|
||||
const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||
);
|
||||
|
||||
export const testIDs = StrictDict({
|
||||
contentIFrame: 'content-iframe-test-id',
|
||||
modalIFrame: 'modal-iframe-test-id',
|
||||
});
|
||||
|
||||
const ContentIFrame = ({
|
||||
iframeUrl,
|
||||
shouldShowContent,
|
||||
showContent,
|
||||
loadingMessage,
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
childBlocks,
|
||||
}) => {
|
||||
const {
|
||||
handleIFrameLoad,
|
||||
hasLoaded,
|
||||
iframeHeight,
|
||||
showError,
|
||||
} = hooks.useIFrameBehavior({
|
||||
elementId,
|
||||
id,
|
||||
iframeUrl,
|
||||
onLoaded,
|
||||
});
|
||||
|
||||
const {
|
||||
modalOptions,
|
||||
handleModalClose,
|
||||
} = hooks.useModalIFrameData();
|
||||
handleIFrameLoad,
|
||||
iframeHeight,
|
||||
} = hooks.useIFrameBehavior({
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
});
|
||||
|
||||
const contentIFrameProps = {
|
||||
id: elementId,
|
||||
src: iframeUrl,
|
||||
allow: IFRAME_FEATURE_POLICY,
|
||||
allowFullScreen: true,
|
||||
height: iframeHeight,
|
||||
scrolling: 'no',
|
||||
referrerPolicy: 'origin',
|
||||
onLoad: handleIFrameLoad,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(shouldShowContent && !hasLoaded) && (
|
||||
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
|
||||
)}
|
||||
{shouldShowContent && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
|
||||
</div>
|
||||
)}
|
||||
{modalOptions.isOpen && (
|
||||
<Modal
|
||||
body={modalOptions.body
|
||||
const renderModal = () => (
|
||||
<Modal
|
||||
body={(
|
||||
<>
|
||||
{modalOptions.body
|
||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||
: (
|
||||
<iframe
|
||||
@@ -84,14 +58,59 @@ const ContentIFrame = ({
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.url}
|
||||
style={{ width: '100%', height: modalOptions.height }}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
dialogClassName="modal-lti"
|
||||
onClose={handleModalClose}
|
||||
open
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
onClose={handleModalClose}
|
||||
open
|
||||
dialogClassName="modal-lti"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderChild = (childBlock) => {
|
||||
const Renderer = renderers[childBlock.type];
|
||||
return (<Renderer key={childBlock.id} {...childBlock.student_view_data} block={childBlock} />);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (iframeUrl) {
|
||||
return (
|
||||
<>
|
||||
{!hasLoaded && (
|
||||
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
|
||||
)}
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id={elementId}
|
||||
title={title}
|
||||
src={iframeUrl}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
onLoad={handleIFrameLoad}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{childBlocks.map(renderChild)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showContent && renderContent()}
|
||||
{modalOptions.open && renderModal()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -99,11 +118,17 @@ const ContentIFrame = ({
|
||||
ContentIFrame.propTypes = {
|
||||
iframeUrl: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
shouldShowContent: PropTypes.bool.isRequired,
|
||||
showContent: PropTypes.bool.isRequired,
|
||||
loadingMessage: PropTypes.node.isRequired,
|
||||
elementId: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
title: PropTypes.node.isRequired,
|
||||
childBlocks: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
student_view_data: PropTypes.shape({
|
||||
enabled: PropTypes.bool,
|
||||
}),
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
ContentIFrame.defaultProps = {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { Modal } from '@edx/paragon';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame';
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' }));
|
||||
|
||||
jest.mock('@edx/paragon', () => ({ Modal: 'Modal' }));
|
||||
|
||||
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useIFrameBehavior: jest.fn(),
|
||||
useModalIFrameData: jest.fn(),
|
||||
}));
|
||||
|
||||
const iframeBehavior = {
|
||||
handleIFrameLoad: jest.fn().mockName('IFrameBehavior.handleIFrameLoad'),
|
||||
hasLoaded: false,
|
||||
iframeHeight: 20,
|
||||
showError: false,
|
||||
};
|
||||
|
||||
const modalOptions = {
|
||||
closed: {
|
||||
isOpen: false,
|
||||
},
|
||||
withBody: {
|
||||
body: 'test-body',
|
||||
isOpen: true,
|
||||
},
|
||||
withUrl: {
|
||||
isOpen: true,
|
||||
title: 'test-modal-title',
|
||||
url: 'test-modal-url',
|
||||
height: 'test-height',
|
||||
},
|
||||
};
|
||||
|
||||
const modalIFrameData = {
|
||||
modalOptions: modalOptions.closed,
|
||||
handleModalClose: jest.fn().mockName('modalIFrameOptions.handleModalClose'),
|
||||
};
|
||||
|
||||
hooks.useIFrameBehavior.mockReturnValue(iframeBehavior);
|
||||
hooks.useModalIFrameData.mockReturnValue(modalIFrameData);
|
||||
|
||||
const props = {
|
||||
iframeUrl: 'test-iframe-url',
|
||||
shouldShowContent: true,
|
||||
loadingMessage: 'test-loading-message',
|
||||
id: 'test-id',
|
||||
elementId: 'test-element-id',
|
||||
onLoaded: jest.fn().mockName('props.onLoaded'),
|
||||
title: 'test-title',
|
||||
};
|
||||
|
||||
let el;
|
||||
describe('ContentIFrame Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
});
|
||||
it('initializes iframe behavior hook', () => {
|
||||
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
|
||||
elementId: props.elementId,
|
||||
id: props.id,
|
||||
iframeUrl: props.iframeUrl,
|
||||
onLoaded: props.onLoaded,
|
||||
});
|
||||
});
|
||||
it('initializes modal iframe data', () => {
|
||||
expect(hooks.useModalIFrameData).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
let component;
|
||||
describe('if shouldShowContent', () => {
|
||||
describe('if not hasLoaded', () => {
|
||||
it('displays errorPage if showError', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(ErrorPage).length).toEqual(1);
|
||||
});
|
||||
it('displays PageLoading component if not showError', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(PageLoading);
|
||||
expect(component.props.srMessage).toEqual(props.loadingMessage);
|
||||
});
|
||||
});
|
||||
describe('hasLoaded', () => {
|
||||
it('does not display PageLoading or ErrorPage', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(PageLoading).length).toEqual(0);
|
||||
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
|
||||
});
|
||||
});
|
||||
it('display iframe with props from hooks', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByTestId(testIDs.contentIFrame);
|
||||
expect(component.props).toEqual({
|
||||
allow: IFRAME_FEATURE_POLICY,
|
||||
allowFullScreen: true,
|
||||
scrolling: 'no',
|
||||
referrerPolicy: 'origin',
|
||||
title: props.title,
|
||||
id: props.elementId,
|
||||
src: props.iframeUrl,
|
||||
height: iframeBehavior.iframeHeight,
|
||||
onLoad: iframeBehavior.handleIFrameLoad,
|
||||
'data-testid': testIDs.contentIFrame,
|
||||
});
|
||||
});
|
||||
});
|
||||
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);
|
||||
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
|
||||
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
|
||||
});
|
||||
});
|
||||
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.isOpen', () => {
|
||||
const testModalOpenAndHandleClose = () => {
|
||||
test('Modal component isOpen, with handleModalClose from hook', () => {
|
||||
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
|
||||
});
|
||||
};
|
||||
describe('body modal', () => {
|
||||
beforeEach(() => {
|
||||
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(Modal);
|
||||
});
|
||||
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
|
||||
expect(component.props.body).toEqual(<div className="unit-modal">{modalOptions.withBody.body}</div>);
|
||||
});
|
||||
testModalOpenAndHandleClose();
|
||||
});
|
||||
describe('url modal', () => {
|
||||
beforeEach(() => {
|
||||
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(Modal);
|
||||
});
|
||||
testModalOpenAndHandleClose();
|
||||
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
|
||||
expect(component.props.body).toEqual(
|
||||
<iframe
|
||||
title={modalOptions.withUrl.title}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.withUrl.url}
|
||||
style={{ width: '100%', height: modalOptions.withUrl.height }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/courseware/course/sequence/Unit/LocalIFrame.jsx
Normal file
23
src/courseware/course/sequence/Unit/LocalIFrame.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const LocalIFrame = ({
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const [contentRef, setContentRef] = React.useState(null);
|
||||
const mountNode = contentRef?.contentWindow?.document?.body;
|
||||
return (
|
||||
<iframe title={title} {...props} ref={setContentRef}>
|
||||
{mountNode && createPortal(children, mountNode)}
|
||||
</iframe>
|
||||
);
|
||||
};
|
||||
|
||||
LocalIFrame.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
};
|
||||
export default LocalIFrame;
|
||||
@@ -1,50 +0,0 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
|
||||
import messages from '../messages';
|
||||
import HonorCode from '../honor-code';
|
||||
import LockPaywall from '../lock-paywall';
|
||||
import * as hooks from './hooks';
|
||||
import { modelKeys } from './constants';
|
||||
|
||||
const UnitSuspense = ({
|
||||
courseId,
|
||||
id,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const shouldDisplayHonorCode = hooks.useShouldDisplayHonorCode({ courseId, id });
|
||||
const unit = useModel(modelKeys.units, id);
|
||||
const meta = useModel(modelKeys.coursewareMeta, courseId);
|
||||
const shouldDisplayContentGating = (
|
||||
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
|
||||
);
|
||||
|
||||
const suspenseComponent = (message, Component) => (
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
|
||||
<Component courseId={courseId} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayContentGating && (
|
||||
suspenseComponent(messages.loadingLockedContent, LockPaywall)
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
suspenseComponent(messages.loadingHonorCode, HonorCode)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
UnitSuspense.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default UnitSuspense;
|
||||
@@ -1,106 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
|
||||
import messages from '../messages';
|
||||
import HonorCode from '../honor-code';
|
||||
import LockPaywall from '../lock-paywall';
|
||||
import hooks from './hooks';
|
||||
import { modelKeys } from './constants';
|
||||
|
||||
import UnitSuspense from './UnitSuspense';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
defineMessages: m => m,
|
||||
useIntl: () => ({ formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage }),
|
||||
}));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
Suspense: 'Suspense',
|
||||
}));
|
||||
|
||||
jest.mock('../honor-code', () => 'HonorCode');
|
||||
jest.mock('../lock-paywall', () => 'LockPaywall');
|
||||
jest.mock('../../../../generic/model-store', () => ({ useModel: jest.fn() }));
|
||||
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useShouldDisplayHonorCode: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockModels = (enabled, containsContent) => {
|
||||
useModel.mockImplementation((key) => (
|
||||
key === modelKeys.units
|
||||
? { containsContentTypeGatedContent: containsContent }
|
||||
: { contentTypeGatingEnabled: enabled }
|
||||
));
|
||||
};
|
||||
|
||||
const props = {
|
||||
courseId: 'test-course-id',
|
||||
id: 'test-id',
|
||||
};
|
||||
|
||||
let el;
|
||||
describe('UnitSuspense component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockModels(false, false);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes models', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const { calls } = useModel.mock;
|
||||
const [unitCall] = calls.filter(call => call[0] === modelKeys.units);
|
||||
const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta);
|
||||
expect(unitCall[1]).toEqual(props.id);
|
||||
expect(metaCall[1]).toEqual(props.courseId);
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('LockPaywall', () => {
|
||||
const testNoPaywall = () => {
|
||||
it('does not display LockPaywal', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
|
||||
});
|
||||
};
|
||||
describe('gating not enabled', () => { testNoPaywall(); });
|
||||
describe('gating enabled, but no gated content included', () => {
|
||||
beforeEach(() => { mockModels(true, false); });
|
||||
testNoPaywall();
|
||||
});
|
||||
describe('gating enabled, gated content included', () => {
|
||||
beforeEach(() => { mockModels(true, true); });
|
||||
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const [component] = el.instance.findByType(LockPaywall);
|
||||
expect(component.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.props.fallback)
|
||||
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('HonorCode', () => {
|
||||
it('does not display HonorCode if useShouldDisplayHonorCode => false', () => {
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
expect(el.instance.findByType(HonorCode).length).toEqual(0);
|
||||
});
|
||||
it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => {
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const [component] = el.instance.findByType(HonorCode);
|
||||
expect(component.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.props.fallback)
|
||||
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Unit component output BookmarkButton props bookmarked, bookmark update pending snapshot 1`] = `
|
||||
<BookmarkButton
|
||||
isBookmarked={true}
|
||||
isProcessing={false}
|
||||
unitId="unit-id"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Unit component output BookmarkButton props not bookmarked, bookmark update loading snapshot 1`] = `
|
||||
<BookmarkButton
|
||||
isBookmarked={false}
|
||||
isProcessing={true}
|
||||
unitId="unit-id"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Unit component output snapshot: not bookmarked, do not show content 1`] = `
|
||||
<div
|
||||
className="unit"
|
||||
>
|
||||
<h1
|
||||
className="mb-0 h3"
|
||||
>
|
||||
unit-title
|
||||
</h1>
|
||||
<h2
|
||||
className="sr-only"
|
||||
>
|
||||
Level 2 headings may be created by course providers in the future.
|
||||
</h2>
|
||||
<BookmarkButton
|
||||
isBookmarked={false}
|
||||
isProcessing={false}
|
||||
unitId="unit-id"
|
||||
/>
|
||||
<UnitSuspense
|
||||
courseId="test-course-id"
|
||||
id="test-props-id"
|
||||
/>
|
||||
<ContentIFrame
|
||||
elementId="unit-iframe"
|
||||
id="test-props-id"
|
||||
loadingMessage="Loading learning sequence..."
|
||||
onLoaded={[MockFunction props.onLoaded]}
|
||||
shouldShowContent={true}
|
||||
title="unit-title"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,26 +1,12 @@
|
||||
import { StrictDict } from '@edx/react-unit-test-utils/dist';
|
||||
import HTMLRenderer from './renderers/HTMLRenderer';
|
||||
|
||||
export const modelKeys = StrictDict({
|
||||
units: 'units',
|
||||
coursewareMeta: 'coursewareMeta',
|
||||
});
|
||||
export const renderers = {
|
||||
html: HTMLRenderer,
|
||||
};
|
||||
|
||||
export const views = StrictDict({
|
||||
student: 'student_view',
|
||||
public: 'public_view',
|
||||
});
|
||||
export const FRendlyTypes = Object.keys(renderers);
|
||||
|
||||
export const loadingState = 'loading';
|
||||
|
||||
export const messageTypes = StrictDict({
|
||||
modal: 'plugin.modal',
|
||||
resize: 'plugin.resize',
|
||||
videoFullScreen: 'plugin.videoFullScreen',
|
||||
});
|
||||
|
||||
export default StrictDict({
|
||||
modelKeys,
|
||||
views,
|
||||
loadingState,
|
||||
messageTypes,
|
||||
});
|
||||
export default {
|
||||
renderers,
|
||||
FRendlyTypes,
|
||||
};
|
||||
|
||||
211
src/courseware/course/sequence/Unit/hooks.js
Normal file
211
src/courseware/course/sequence/Unit/hooks.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { processEvent } from '../../../../course-home/data/thunks';
|
||||
import { useEventListener } from '../../../../generic/hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import { fetchCourse } from '../../../data';
|
||||
|
||||
import { FRendlyTypes } from './constants';
|
||||
|
||||
const useFetchStudentData = ({
|
||||
id,
|
||||
}) => {
|
||||
const [blocks, setBlocks] = useState(null);
|
||||
const [children, setChildren] = useState(null);
|
||||
const [isFRendly, setIsFRendly] = useState(false);
|
||||
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsFRendly(children.every(child => FRendlyTypes.includes(child.type)));
|
||||
}
|
||||
}, [children, setIsFRendly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocks) {
|
||||
setChildren(blocks[id].children.map(childID => blocks[childID]));
|
||||
}
|
||||
}, [blocks, setChildren]);
|
||||
|
||||
useEffect(() => {
|
||||
let sequenceUrl;
|
||||
if (authenticatedUser) {
|
||||
const { username } = authenticatedUser;
|
||||
sequenceUrl = `${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/${id}?username=${username}&requested_fields=children&depth=all&student_view_data=video,html`;
|
||||
getAuthenticatedHttpClient().get(sequenceUrl).then(response => {
|
||||
console.log({ response });
|
||||
setBlocks(response.data.blocks);
|
||||
});
|
||||
}
|
||||
}, [authenticatedUser, setBlocks]);
|
||||
console.log({ isFRendly, children });
|
||||
return { children, isFRendly };
|
||||
};
|
||||
|
||||
const useUnitData = ({
|
||||
courseId,
|
||||
format,
|
||||
id,
|
||||
}) => {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
if (format) {
|
||||
iframeUrl += `&format=${format}`;
|
||||
}
|
||||
|
||||
const { isFRendly, children } = useFetchStudentData({ id });
|
||||
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
userNeedsIntegritySignature,
|
||||
} = course;
|
||||
|
||||
useEffect(() => {
|
||||
if (userNeedsIntegritySignature && unit.graded) {
|
||||
setShouldDisplayHonorCode(true);
|
||||
} else {
|
||||
setShouldDisplayHonorCode(false);
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
return {
|
||||
contentTypeGatingEnabled,
|
||||
iframeUrl,
|
||||
shouldDisplayHonorCode,
|
||||
unit,
|
||||
isFRendly,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||
* state.
|
||||
*
|
||||
* We were able to solve this error by using a layout effect to update some component state, which
|
||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||
*
|
||||
* If we remove this hook when one of these happens:
|
||||
* 1. React figures out that there's an issue here and fixes a bug.
|
||||
* 2. We cease to use an iframe for unit rendering.
|
||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||
* 4. We stop supporting Firefox.
|
||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||
* so we can fix it.
|
||||
*
|
||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||
*/
|
||||
export const useLoadBearingHook = (id) => {
|
||||
const setValue = useState(0)[1];
|
||||
useLayoutEffect(() => {
|
||||
setValue(currentValue => currentValue + 1);
|
||||
}, [id]);
|
||||
};
|
||||
|
||||
export const sendUrlHashToFrame = (frame) => {
|
||||
const { hash } = window.location;
|
||||
if (hash) {
|
||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||
// hash within the iframe.
|
||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||
}
|
||||
};
|
||||
|
||||
const useIFrameBehavior = ({
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
}) => {
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
|
||||
useEffect(() => {
|
||||
sendUrlHashToFrame(document.getElementById(elementId));
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
|
||||
const receiveMessage = useCallback(({ data }) => {
|
||||
const { type, payload } = data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
/**
|
||||
* onLoad *should* only fire after everything in the iframe has finished its own load events.
|
||||
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
||||
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
||||
* could have given us a 4xx or 5xx response.
|
||||
*/
|
||||
const handleIFrameLoad = () => {
|
||||
if (!hasLoaded) {
|
||||
setShowError(true);
|
||||
}
|
||||
window.onmessage = (e) => {
|
||||
if (e.data.event_name) {
|
||||
dispatch(processEvent(e.data, fetchCourse));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOptions({ open: false });
|
||||
};
|
||||
|
||||
return {
|
||||
iframeHeight,
|
||||
handleCloseModal,
|
||||
modalOptions,
|
||||
handleIFrameLoad,
|
||||
showError,
|
||||
hasLoaded,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
useIFrameBehavior,
|
||||
useUnitData,
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default as useExamAccess } from './useExamAccess';
|
||||
export { default as useIFrameBehavior } from './useIFrameBehavior';
|
||||
export { default as useLoadBearingHook } from './useLoadBearingHook';
|
||||
export { default as useModalIFrameData } from './useModalIFrameData';
|
||||
export { default as useShouldDisplayHonorCode } from './useShouldDisplayHonorCode';
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
accessToken: 'accessToken',
|
||||
blockAccess: 'blockAccess',
|
||||
});
|
||||
|
||||
const useExamAccess = ({
|
||||
id,
|
||||
}) => {
|
||||
const [accessToken, setAccessToken] = useKeyedState(stateKeys.accessToken, '');
|
||||
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam());
|
||||
React.useEffect(() => {
|
||||
if (isExam()) {
|
||||
fetchExamAccess()
|
||||
.finally(() => {
|
||||
const examAccess = getExamAccess();
|
||||
setAccessToken(examAccess);
|
||||
setBlockAccess(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error);
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return {
|
||||
blockAccess,
|
||||
accessToken,
|
||||
};
|
||||
};
|
||||
|
||||
export default useExamAccess;
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
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) => {
|
||||
const { calls } = React.useEffect.mock;
|
||||
const match = calls.filter(call => isEqual(call[1], prereqs));
|
||||
return match.length ? match[0][0] : null;
|
||||
};
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-lib-special-exams', () => ({
|
||||
getExamAccess: jest.fn(),
|
||||
fetchExamAccess: jest.fn(),
|
||||
isExam: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
const id = 'test-id';
|
||||
|
||||
const mockFetchExamAccess = Promise.resolve();
|
||||
fetchExamAccess.mockReturnValue(mockFetchExamAccess);
|
||||
|
||||
const testAccessToken = 'test-access-token';
|
||||
getExamAccess.mockReturnValue(testAccessToken);
|
||||
|
||||
describe('useExamAccess hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes access token to empty string', () => {
|
||||
useExamAccess({ id });
|
||||
state.expectInitializedWith(stateKeys.accessToken, '');
|
||||
});
|
||||
it('initializes blockAccess to true if is an exam', () => {
|
||||
useExamAccess({ id });
|
||||
state.expectInitializedWith(stateKeys.blockAccess, false);
|
||||
});
|
||||
it('initializes blockAccess to false if is not an exam', () => {
|
||||
isExam.mockReturnValueOnce(true);
|
||||
useExamAccess({ id });
|
||||
state.expectInitializedWith(stateKeys.blockAccess, true);
|
||||
});
|
||||
describe('effects - on id change', () => {
|
||||
let useEffectCb;
|
||||
beforeEach(() => {
|
||||
useExamAccess({ id });
|
||||
useEffectCb = getEffect([id], React);
|
||||
});
|
||||
it('does not call fetchExamAccess if not an exam', () => {
|
||||
useEffectCb();
|
||||
expect(fetchExamAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
it('fetches and sets exam access if isExam', async () => {
|
||||
isExam.mockReturnValueOnce(true);
|
||||
useEffectCb();
|
||||
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
|
||||
state.expectSetStateCalledWith(stateKeys.accessToken, testAccessToken);
|
||||
state.expectSetStateCalledWith(stateKeys.blockAccess, false);
|
||||
});
|
||||
const testError = 'test-error';
|
||||
it('logs error if fetchExamAccess fails', async () => {
|
||||
isExam.mockReturnValueOnce(true);
|
||||
fetchExamAccess.mockReturnValueOnce(Promise.reject(testError));
|
||||
useEffectCb();
|
||||
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
|
||||
expect(logError).toHaveBeenCalledWith(testError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('forwards blockAccess and accessToken from state fields', () => {
|
||||
const testBlockAccess = 'test-block-access';
|
||||
state.mockVals({
|
||||
blockAccess: testBlockAccess,
|
||||
accessToken: testAccessToken,
|
||||
});
|
||||
const out = useExamAccess({ id });
|
||||
expect(out.blockAccess).toEqual(testBlockAccess);
|
||||
expect(out.accessToken).toEqual(testAccessToken);
|
||||
state.resetVals();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { fetchCourse } from '../../../../data';
|
||||
import { processEvent } from '../../../../../course-home/data/thunks';
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
import { messageTypes } from '../constants';
|
||||
|
||||
import useLoadBearingHook from './useLoadBearingHook';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
iframeHeight: 'iframeHeight',
|
||||
hasLoaded: 'hasLoaded',
|
||||
showError: 'showError',
|
||||
windowTopOffset: 'windowTopOffset',
|
||||
});
|
||||
|
||||
const useIFrameBehavior = ({
|
||||
elementId,
|
||||
id,
|
||||
iframeUrl,
|
||||
onLoaded,
|
||||
}) => {
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0);
|
||||
const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false);
|
||||
const [showError, setShowError] = useKeyedState(stateKeys.showError, false);
|
||||
const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const frame = document.getElementById(elementId);
|
||||
const { hash } = window.location;
|
||||
if (hash) {
|
||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||
// hash within the iframe.
|
||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||
}
|
||||
}, [id, onLoaded, iframeHeight, hasLoaded]);
|
||||
|
||||
const receiveMessage = React.useCallback(({ data }) => {
|
||||
const { type, payload } = data;
|
||||
if (type === messageTypes.resize) {
|
||||
setIframeHeight(payload.height);
|
||||
|
||||
// We observe exit from the video xblock fullscreen mode
|
||||
// and scroll to the previously saved scroll position
|
||||
if (windowTopOffset !== null) {
|
||||
window.scrollTo(0, Number(windowTopOffset));
|
||||
}
|
||||
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === messageTypes.videoFullScreen) {
|
||||
// We listen for this message from LMS to know when we need to
|
||||
// save or reset scroll position on toggle video xblock fullscreen mode
|
||||
setWindowTopOffset(payload.open ? window.scrollY : null);
|
||||
} else if (data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
onLoaded,
|
||||
hasLoaded,
|
||||
setHasLoaded,
|
||||
iframeHeight,
|
||||
setIframeHeight,
|
||||
windowTopOffset,
|
||||
setWindowTopOffset,
|
||||
]);
|
||||
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
/**
|
||||
* onLoad *should* only fire after everything in the iframe has finished its own load events.
|
||||
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
||||
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
||||
* could have given us a 4xx or 5xx response.
|
||||
*/
|
||||
|
||||
const handleIFrameLoad = () => {
|
||||
if (!hasLoaded) {
|
||||
setShowError(true);
|
||||
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
|
||||
iframeUrl,
|
||||
});
|
||||
}
|
||||
window.onmessage = (e) => {
|
||||
if (e.data.event_name) {
|
||||
dispatch(processEvent(e.data, fetchCourse));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
iframeHeight,
|
||||
handleIFrameLoad,
|
||||
showError,
|
||||
hasLoaded,
|
||||
};
|
||||
};
|
||||
|
||||
export default useIFrameBehavior;
|
||||
@@ -1,295 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { fetchCourse } from '../../../../data';
|
||||
import { processEvent } from '../../../../../course-home/data/thunks';
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
|
||||
import { messageTypes } from '../constants';
|
||||
|
||||
import useIFrameBehavior, { stateKeys } from './useIFrameBehavior';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn(),
|
||||
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./useLoadBearingHook', () => jest.fn());
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../data', () => ({
|
||||
fetchCourse: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../../course-home/data/thunks', () => ({
|
||||
processEvent: jest.fn((...args) => ({ processEvent: args })),
|
||||
}));
|
||||
jest.mock('../../../../../generic/hooks', () => ({
|
||||
useEventListener: jest.fn(),
|
||||
}));
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
const props = {
|
||||
elementId: 'test-element-id',
|
||||
id: 'test-id',
|
||||
iframeUrl: 'test-iframe-url',
|
||||
onLoaded: jest.fn(),
|
||||
};
|
||||
|
||||
const testIFrameHeight = 42;
|
||||
|
||||
const config = { LMS_BASE_URL: 'test-base-url' };
|
||||
getConfig.mockReturnValue(config);
|
||||
|
||||
const dispatch = jest.fn();
|
||||
useDispatch.mockReturnValue(dispatch);
|
||||
|
||||
const postMessage = jest.fn();
|
||||
const frame = { contentWindow: { postMessage } };
|
||||
const mockGetElementById = jest.fn(() => frame);
|
||||
const testHash = '#test-hash';
|
||||
|
||||
const defaultStateVals = {
|
||||
iframeHeight: 0,
|
||||
hasLoaded: false,
|
||||
showError: false,
|
||||
windowTopOffset: null,
|
||||
};
|
||||
|
||||
const stateVals = {
|
||||
iframeHeight: testIFrameHeight,
|
||||
hasLoaded: true,
|
||||
showError: true,
|
||||
windowTopOffset: 32,
|
||||
};
|
||||
|
||||
describe('useIFrameBehavior hook', () => {
|
||||
let hook;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
});
|
||||
afterEach(() => {
|
||||
state.resetVals();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes iframe height to 0 and error/loaded values to false', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
state.expectInitializedWith(stateKeys.iframeHeight, 0);
|
||||
state.expectInitializedWith(stateKeys.hasLoaded, false);
|
||||
state.expectInitializedWith(stateKeys.showError, false);
|
||||
state.expectInitializedWith(stateKeys.windowTopOffset, null);
|
||||
});
|
||||
describe('effects - on frame change', () => {
|
||||
let oldGetElement;
|
||||
beforeEach(() => {
|
||||
global.window ??= Object.create(window);
|
||||
Object.defineProperty(window, 'location', { value: {}, writable: true });
|
||||
state.mockVals(stateVals);
|
||||
oldGetElement = document.getElementById;
|
||||
document.getElementById = mockGetElementById;
|
||||
});
|
||||
afterEach(() => {
|
||||
state.resetVals();
|
||||
document.getElementById = oldGetElement;
|
||||
});
|
||||
it('does not post url hash if the window does not have one', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
const cb = getEffects([
|
||||
props.id,
|
||||
props.onLoaded,
|
||||
testIFrameHeight,
|
||||
true,
|
||||
], React)[0];
|
||||
cb();
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
it('posts url hash if the window has one', () => {
|
||||
window.location.hash = testHash;
|
||||
hook = useIFrameBehavior(props);
|
||||
const cb = getEffects([
|
||||
props.id,
|
||||
props.onLoaded,
|
||||
testIFrameHeight,
|
||||
true,
|
||||
], React)[0];
|
||||
cb();
|
||||
expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL);
|
||||
});
|
||||
});
|
||||
describe('event listener', () => {
|
||||
it('calls eventListener with prepared callback', () => {
|
||||
state.mockVals(stateVals);
|
||||
hook = useIFrameBehavior(props);
|
||||
const [call] = useEventListener.mock.calls;
|
||||
expect(call[0]).toEqual('message');
|
||||
expect(call[1].prereqs).toEqual([
|
||||
props.id,
|
||||
props.onLoaded,
|
||||
state.values.hasLoaded,
|
||||
state.setState.hasLoaded,
|
||||
state.values.iframeHeight,
|
||||
state.setState.iframeHeight,
|
||||
state.values.windowTopOffset,
|
||||
state.setState.windowTopOffset,
|
||||
]);
|
||||
});
|
||||
describe('resize message', () => {
|
||||
const resizeMessage = (height = 23) => ({
|
||||
data: { type: messageTypes.resize, payload: { height } },
|
||||
});
|
||||
const testSetIFrameHeight = (height = 23) => {
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(resizeMessage(height));
|
||||
expect(state.setState.iframeHeight).toHaveBeenCalledWith(height);
|
||||
};
|
||||
const testOnlySetsHeight = () => {
|
||||
it('sets iframe height with payload height', () => {
|
||||
testSetIFrameHeight();
|
||||
});
|
||||
it('does not set hasLoaded', () => {
|
||||
expect(state.setState.hasLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
describe('hasLoaded', () => {
|
||||
beforeEach(() => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
hook = useIFrameBehavior(props);
|
||||
});
|
||||
testOnlySetsHeight();
|
||||
});
|
||||
describe('iframeHeight is not 0', () => {
|
||||
beforeEach(() => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
hook = useIFrameBehavior(props);
|
||||
});
|
||||
testOnlySetsHeight();
|
||||
});
|
||||
describe('payload height is 0', () => {
|
||||
beforeEach(() => { hook = useIFrameBehavior(props); });
|
||||
testOnlySetsHeight(0);
|
||||
});
|
||||
describe('payload is present but uninitialized', () => {
|
||||
it('sets iframe height with payload height', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
testSetIFrameHeight();
|
||||
});
|
||||
it('sets hasLoaded and calls onLoaded', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(resizeMessage());
|
||||
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
|
||||
expect(props.onLoaded).toHaveBeenCalled();
|
||||
});
|
||||
test('onLoaded is optional', () => {
|
||||
hook = useIFrameBehavior({ ...props, onLoaded: undefined });
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(resizeMessage());
|
||||
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
it('scrolls to current window vertical offset if one is set', () => {
|
||||
const windowTopOffset = 32;
|
||||
state.mockVals({ ...defaultStateVals, windowTopOffset });
|
||||
hook = useIFrameBehavior(props);
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(resizeMessage());
|
||||
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
|
||||
});
|
||||
it('does not scroll if towverticalp offset is not set', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(resizeMessage());
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('video fullscreen message', () => {
|
||||
let cb;
|
||||
const scrollY = 23;
|
||||
const fullScreenMessage = (open) => ({
|
||||
data: { type: messageTypes.videoFullScreen, payload: { open } },
|
||||
});
|
||||
beforeEach(() => {
|
||||
window.scrollY = scrollY;
|
||||
hook = useIFrameBehavior(props);
|
||||
[[, { cb }]] = useEventListener.mock.calls;
|
||||
});
|
||||
it('sets window top offset based on window.scrollY if opening the video', () => {
|
||||
cb(fullScreenMessage(true));
|
||||
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(scrollY);
|
||||
});
|
||||
it('sets window top offset to null if closing the video', () => {
|
||||
cb(fullScreenMessage(false));
|
||||
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
describe('offset message', () => {
|
||||
it('scrolls to data offset', () => {
|
||||
const offsetTop = 44;
|
||||
const mockGetEl = jest.fn(() => ({ offsetTop }));
|
||||
|
||||
const oldGetElement = document.getElementById;
|
||||
document.getElementById = mockGetEl;
|
||||
const oldScrollTo = window.scrollTo;
|
||||
window.scrollTo = jest.fn();
|
||||
hook = useIFrameBehavior(props);
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
const offset = 99;
|
||||
cb({ data: { offset } });
|
||||
expect(window.scrollTo).toHaveBeenCalledWith(0, offset + offsetTop);
|
||||
expect(mockGetEl).toHaveBeenCalledWith('unit-iframe');
|
||||
document.getElementById = oldGetElement;
|
||||
window.scrollTo = oldScrollTo;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleIFrameLoad', () => {
|
||||
it('sets and logs error if has not loaded', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
hook.handleIFrameLoad();
|
||||
expect(state.setState.showError).toHaveBeenCalledWith(true);
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
it('does not set/log errors if loaded', () => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
hook = useIFrameBehavior(props);
|
||||
hook.handleIFrameLoad();
|
||||
expect(state.setState.showError).not.toHaveBeenCalled();
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
it('registers an event handler to process fetchCourse events.', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
hook.handleIFrameLoad();
|
||||
const eventName = 'test-event-name';
|
||||
const event = { data: { event_name: eventName } };
|
||||
window.onmessage(event);
|
||||
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
|
||||
});
|
||||
});
|
||||
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
|
||||
state.mockVals(stateVals);
|
||||
hook = useIFrameBehavior(props);
|
||||
expect(hook.iframeHeight).toEqual(stateVals.iframeHeight);
|
||||
expect(hook.showError).toEqual(stateVals.showError);
|
||||
expect(hook.hasLoaded).toEqual(stateVals.hasLoaded);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||
* state.
|
||||
*
|
||||
* We were able to solve this error by using a layout effect to update some component state, which
|
||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||
*
|
||||
* If we remove this hook when one of these happens:
|
||||
* 1. React figures out that there's an issue here and fixes a bug.
|
||||
* 2. We cease to use an iframe for unit rendering.
|
||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||
* 4. We stop supporting Firefox.
|
||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||
* so we can fix it.
|
||||
*
|
||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||
*/
|
||||
const useLoadBearingHook = (id) => {
|
||||
const setValue = React.useState(0)[1];
|
||||
React.useLayoutEffect(() => {
|
||||
setValue(currentValue => currentValue + 1);
|
||||
}, [id]);
|
||||
};
|
||||
|
||||
export default useLoadBearingHook;
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import useLoadBearingHook from './useLoadBearingHook';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useState: jest.fn(),
|
||||
useLayoutEffect: jest.fn(),
|
||||
}));
|
||||
|
||||
const setState = jest.fn();
|
||||
React.useState.mockImplementation((val) => [val, setState]);
|
||||
|
||||
const id = 'test-id';
|
||||
describe('useLoadBearingHook', () => {
|
||||
it('increments a simple value w/ useLayoutEffect', () => {
|
||||
useLoadBearingHook(id);
|
||||
expect(React.useState).toHaveBeenCalledWith(0);
|
||||
const [[layoutCb, prereqs]] = React.useLayoutEffect.mock.calls;
|
||||
expect(prereqs).toEqual([id]);
|
||||
layoutCb();
|
||||
const [[setValueCb]] = setState.mock.calls;
|
||||
expect(setValueCb(1)).toEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
|
||||
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
isOpen: 'isOpen',
|
||||
options: 'options',
|
||||
});
|
||||
|
||||
export const DEFAULT_HEIGHT = '100vh';
|
||||
|
||||
const useModalIFrameBehavior = () => {
|
||||
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') {
|
||||
setOptions((current) => ({ ...current, ...payload }));
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, []);
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return {
|
||||
handleModalClose,
|
||||
modalOptions: { isOpen, ...options },
|
||||
};
|
||||
};
|
||||
|
||||
export default useModalIFrameBehavior;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
import { messageTypes } from '../constants';
|
||||
|
||||
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
|
||||
}));
|
||||
jest.mock('../../../../../generic/hooks', () => ({
|
||||
useEventListener: jest.fn(),
|
||||
}));
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
describe('useModalIFrameBehavior', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes isOpen to false', () => {
|
||||
useModalIFrameBehavior();
|
||||
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.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.isOpen, false);
|
||||
});
|
||||
it('forwards modalOptions from state values', () => {
|
||||
const modalOptions = { test: 'options' };
|
||||
state.mockVals({
|
||||
[stateKeys.options]: modalOptions,
|
||||
[stateKeys.isOpen]: true,
|
||||
});
|
||||
expect(useModalIFrameBehavior().modalOptions).toEqual({
|
||||
...modalOptions,
|
||||
isOpen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
|
||||
import { modelKeys } from '../constants';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
shouldDisplay: 'shouldDisplay',
|
||||
});
|
||||
|
||||
/**
|
||||
* @return {bool} should the honor code be displayed?
|
||||
*/
|
||||
const useShouldDisplayHonorCode = ({ id, courseId }) => {
|
||||
const [shouldDisplay, setShouldDisplay] = useKeyedState(stateKeys.shouldDisplay, false);
|
||||
|
||||
const { graded } = useModel(modelKeys.units, id);
|
||||
const { userNeedsIntegritySignature } = useModel(modelKeys.coursewareMeta, courseId);
|
||||
|
||||
React.useEffect(() => {
|
||||
setShouldDisplay(userNeedsIntegritySignature && graded);
|
||||
}, [setShouldDisplay, userNeedsIntegritySignature]);
|
||||
|
||||
return shouldDisplay;
|
||||
};
|
||||
|
||||
export default useShouldDisplayHonorCode;
|
||||
@@ -1,79 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
|
||||
import { modelKeys } from '../constants';
|
||||
|
||||
import useShouldDisplayHonorCode, { stateKeys } from './useShouldDisplayHonorCode';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../../generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
const props = {
|
||||
id: 'test-id',
|
||||
courseId: 'test-course-id',
|
||||
};
|
||||
|
||||
const mockModels = (graded, userNeedsIntegritySignature) => {
|
||||
useModel.mockImplementation((key) => (
|
||||
(key === modelKeys.units) ? { graded } : { userNeedsIntegritySignature }
|
||||
));
|
||||
};
|
||||
|
||||
describe('useShouldDisplayHonorCode hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockModels(false, false);
|
||||
state.mock();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes shouldDisplay to false', () => {
|
||||
useShouldDisplayHonorCode(props);
|
||||
state.expectInitializedWith(stateKeys.shouldDisplay, false);
|
||||
});
|
||||
describe('effect - on userNeedsIntegritySignature', () => {
|
||||
describe('graded and needs integrity signature', () => {
|
||||
it('sets shouldDisplay(true)', () => {
|
||||
mockModels(true, true);
|
||||
useShouldDisplayHonorCode(props);
|
||||
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
|
||||
cb();
|
||||
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
describe('not graded', () => {
|
||||
it('sets should not display', () => {
|
||||
mockModels(true, false);
|
||||
useShouldDisplayHonorCode(props);
|
||||
const cb = getEffects([state.setState.shouldDisplay, false], React)[0];
|
||||
cb();
|
||||
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
describe('does not need integrity signature', () => {
|
||||
it('sets should not display', () => {
|
||||
mockModels(false, true);
|
||||
useShouldDisplayHonorCode(props);
|
||||
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
|
||||
cb();
|
||||
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('returns shouldDisplay value from state', () => {
|
||||
const testValue = 'test-value';
|
||||
state.mockVal(stateKeys.shouldDisplay, testValue);
|
||||
expect(useShouldDisplayHonorCode(props)).toEqual(testValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,14 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Suspense } from 'react';
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
||||
import messages from '../messages';
|
||||
import ContentIFrame from './ContentIFrame';
|
||||
import UnitSuspense from './UnitSuspense';
|
||||
import { modelKeys, views } from './constants';
|
||||
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
|
||||
import { getIFrameUrl } from './urls';
|
||||
import hooks from './hooks';
|
||||
|
||||
const HonorCode = React.lazy(() => import('../honor-code'));
|
||||
const LockPaywall = React.lazy(() => import('../lock-paywall'));
|
||||
|
||||
const Unit = ({
|
||||
courseId,
|
||||
@@ -21,18 +17,17 @@ const Unit = ({
|
||||
id,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { authenticatedUser } = React.useContext(AppContext);
|
||||
const examAccess = useExamAccess({ id });
|
||||
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
|
||||
const unit = useModel(modelKeys.units, id);
|
||||
const isProcessing = unit.bookmarkedUpdateState === 'loading';
|
||||
const view = authenticatedUser ? views.student : views.public;
|
||||
|
||||
const iframeUrl = getIFrameUrl({
|
||||
id,
|
||||
view,
|
||||
const {
|
||||
unit,
|
||||
contentTypeGatingEnabled,
|
||||
shouldDisplayHonorCode,
|
||||
iframeUrl,
|
||||
isFRendly,
|
||||
children,
|
||||
} = hooks.useUnitData({
|
||||
courseId,
|
||||
format,
|
||||
examAccess,
|
||||
id,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -42,16 +37,37 @@ const Unit = ({
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={isProcessing}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
<UnitSuspense {...{ courseId, id }} />
|
||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={formatMessage(messages.loadingHonorCode)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<HonorCode courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
<ContentIFrame
|
||||
elementId="unit-iframe"
|
||||
id={id}
|
||||
iframeUrl={iframeUrl}
|
||||
showContent={!shouldDisplayHonorCode}
|
||||
{...(isFRendly ? { childBlocks: children } : { iframeUrl })}
|
||||
loadingMessage={formatMessage(messages.loadingSequence)}
|
||||
id={id}
|
||||
elementId="unit-iframe"
|
||||
onLoaded={onLoaded}
|
||||
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
|
||||
title={unit.title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import React from 'react';
|
||||
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
||||
import UnitSuspense from './UnitSuspense';
|
||||
import ContentIFrame from './ContentIFrame';
|
||||
import Unit from '.';
|
||||
import messages from '../messages';
|
||||
import { getIFrameUrl } from './urls';
|
||||
import { views } from './constants';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
|
||||
return {
|
||||
useIntl: () => ({ formatMessage: utils.formatMessage }),
|
||||
defineMessages: m => m,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
|
||||
jest.mock('../../bookmark/BookmarkButton', () => 'BookmarkButton');
|
||||
jest.mock('./ContentIFrame', () => 'ContentIFrame');
|
||||
jest.mock('./UnitSuspense', () => 'UnitSuspense');
|
||||
jest.mock('../honor-code', () => 'HonorCode');
|
||||
jest.mock('../lock-paywall', () => 'LockPaywall');
|
||||
|
||||
jest.mock('../../../../generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useContext: jest.fn(v => v),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useExamAccess: jest.fn(),
|
||||
useShouldDisplayHonorCode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./urls', () => ({
|
||||
getIFrameUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const props = {
|
||||
courseId: 'test-course-id',
|
||||
format: 'test-format',
|
||||
onLoaded: jest.fn().mockName('props.onLoaded'),
|
||||
id: 'test-props-id',
|
||||
};
|
||||
|
||||
const context = { authenticatedUser: { test: 'user' } };
|
||||
React.useContext.mockReturnValue(context);
|
||||
|
||||
const examAccess = {
|
||||
accessToken: 'test-token',
|
||||
blockAccess: false,
|
||||
};
|
||||
hooks.useExamAccess.mockReturnValue(examAccess);
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValue(false);
|
||||
|
||||
const unit = {
|
||||
id: 'unit-id',
|
||||
title: 'unit-title',
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'pending',
|
||||
};
|
||||
useModel.mockReturnValue(unit);
|
||||
|
||||
let el;
|
||||
describe('Unit component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<Unit {...props} />);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(hooks.useShouldDisplayHonorCode).toHaveBeenCalledWith({
|
||||
courseId: props.courseId,
|
||||
id: props.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
let component;
|
||||
test('snapshot: not bookmarked, do not show content', () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
expect(el.snapshot).toMatchSnapshot();
|
||||
});
|
||||
describe('BookmarkButton props', () => {
|
||||
const renderComponent = () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
[component] = el.instance.findByType(BookmarkButton);
|
||||
};
|
||||
describe('not bookmarked, bookmark update loading', () => {
|
||||
beforeEach(() => {
|
||||
useModel.mockReturnValueOnce({ ...unit, bookmarkedUpdateState: 'loading' });
|
||||
renderComponent();
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(component.snapshot).toMatchSnapshot();
|
||||
});
|
||||
test('props', () => {
|
||||
expect(component.props.isBookmarked).toEqual(false);
|
||||
expect(component.props.isProcessing).toEqual(true);
|
||||
expect(component.props.unitId).toEqual(unit.id);
|
||||
});
|
||||
});
|
||||
describe('bookmarked, bookmark update pending', () => {
|
||||
beforeEach(() => {
|
||||
useModel.mockReturnValueOnce({ ...unit, bookmarked: true });
|
||||
renderComponent();
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(component.snapshot).toMatchSnapshot();
|
||||
});
|
||||
test('props', () => {
|
||||
expect(component.props.isBookmarked).toEqual(true);
|
||||
expect(component.props.isProcessing).toEqual(false);
|
||||
expect(component.props.unitId).toEqual(unit.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
test('UnitSuspense props', () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
[component] = el.instance.findByType(UnitSuspense);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
expect(component.props.id).toEqual(props.id);
|
||||
});
|
||||
describe('ContentIFrame props', () => {
|
||||
const testComponentProps = () => {
|
||||
expect(component.props.elementId).toEqual('unit-iframe');
|
||||
expect(component.props.id).toEqual(props.id);
|
||||
expect(component.props.loadingMessage).toEqual(formatMessage(messages.loadingSequence));
|
||||
expect(component.props.onLoaded).toEqual(props.onLoaded);
|
||||
expect(component.props.title).toEqual(unit.title);
|
||||
};
|
||||
const loadComponent = () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
[component] = el.instance.findByType(ContentIFrame);
|
||||
};
|
||||
describe('shouldShowContent', () => {
|
||||
test('do not show content if displaying honor code', () => {
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.shouldShowContent).toEqual(false);
|
||||
});
|
||||
test('do not show content if examAccess is blocked', () => {
|
||||
hooks.useExamAccess.mockReturnValueOnce({ ...examAccess, blockAccess: true });
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.shouldShowContent).toEqual(false);
|
||||
});
|
||||
test('show content if not displaying honor code or blocked by exam access', () => {
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.shouldShowContent).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('iframeUrl', () => {
|
||||
test('loads iframe url with student view if authenticated user', () => {
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: views.student,
|
||||
format: props.format,
|
||||
examAccess,
|
||||
}));
|
||||
});
|
||||
test('loads iframe url with public view if no authenticated user', () => {
|
||||
React.useContext.mockReturnValueOnce({});
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: views.public,
|
||||
format: props.format,
|
||||
examAccess,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import parse from 'html-react-parser';
|
||||
|
||||
const HTMLRenderer = ({ html }) => {
|
||||
console.log({ html });
|
||||
return (<div dangerouslySetInnerHTML={{ __html: html }} />);
|
||||
// return parse(html);
|
||||
};
|
||||
|
||||
HTMLRenderer.propTypes = {
|
||||
html: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default HTMLRenderer;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
export const iframeParams = {
|
||||
show_title: 0,
|
||||
show_bookmark: 0,
|
||||
recheck_access: 1,
|
||||
};
|
||||
|
||||
export const getIFrameUrl = ({
|
||||
id,
|
||||
view,
|
||||
format,
|
||||
examAccess,
|
||||
}) => {
|
||||
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view,
|
||||
...(format && { format }),
|
||||
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
|
||||
});
|
||||
return `${xblockUrl}?${params}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
getIFrameUrl,
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { stringify } from 'query-string';
|
||||
import { getIFrameUrl, iframeParams } from './urls';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('query-string', () => ({
|
||||
stringify: jest.fn((...args) => ({ stringify: args })),
|
||||
}));
|
||||
|
||||
const config = { LMS_BASE_URL: 'test-lms-url' };
|
||||
getConfig.mockReturnValue(config);
|
||||
|
||||
const props = {
|
||||
id: 'test-id',
|
||||
view: 'test-view',
|
||||
format: 'test-format',
|
||||
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
|
||||
};
|
||||
|
||||
describe('urls module', () => {
|
||||
describe('getIFrameUrl', () => {
|
||||
test('format provided, exam access and token available', () => {
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
format: props.format,
|
||||
exam_access: props.examAccess.accessToken,
|
||||
});
|
||||
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
});
|
||||
test('no format provided, exam access blocked', () => {
|
||||
const params = stringify({ ...iframeParams, view: props.view });
|
||||
expect(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: props.view,
|
||||
examAccess: { blockAccess: true },
|
||||
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,9 +11,8 @@ import messages from './messages';
|
||||
const ContentLock = ({
|
||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = useCallback(() => {
|
||||
navigate(`/course/${courseId}/${prereqId}`);
|
||||
history.push(`/course/${courseId}/${prereqId}`);
|
||||
}, [courseId, prereqId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
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',
|
||||
@@ -25,7 +19,7 @@ describe('Content Lock', () => {
|
||||
});
|
||||
|
||||
it('displays sequence title along with lock icon', () => {
|
||||
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
const { container } = render(<ContentLock {...mockData} />);
|
||||
|
||||
const lockIcon = container.querySelector('svg');
|
||||
expect(lockIcon).toHaveClass('fa-lock');
|
||||
@@ -34,15 +28,16 @@ describe('Content Lock', () => {
|
||||
|
||||
it('displays prerequisite name', () => {
|
||||
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
|
||||
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
render(<ContentLock {...mockData} />);
|
||||
|
||||
expect(screen.getByText(prereqText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click', () => {
|
||||
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
history.push = jest.fn();
|
||||
render(<ContentLock {...mockData} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getConfig, history } 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,
|
||||
@@ -22,7 +20,7 @@ const HonorCode = ({ intl, courseId }) => {
|
||||
const siteName = getConfig().SITE_NAME;
|
||||
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
|
||||
|
||||
const handleCancel = () => navigate(`/course/${courseId}/home`);
|
||||
const handleCancel = () => history.push(`/course/${courseId}/home`);
|
||||
|
||||
const handleAgree = () => dispatch(
|
||||
// If the request is made by a staff user masquerading as a specific learner,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getConfig, history } 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('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Honor Code', () => {
|
||||
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
|
||||
|
||||
it('cancel button links to course home ', async () => {
|
||||
await setupStoreState();
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
render(<HonorCode {...mockData} />);
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
});
|
||||
|
||||
it('calls to save integrity_signature when agreeing', async () => {
|
||||
await setupStoreState({ username: authenticatedUser.username });
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
render(<HonorCode {...mockData} />);
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
|
||||
username: authenticatedUser.username,
|
||||
},
|
||||
);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
render(<HonorCode {...mockData} />);
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
|
||||
username: 'otheruser',
|
||||
},
|
||||
);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
render(<HonorCode {...mockData} />);
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -27,13 +26,12 @@ const SequenceNavigation = ({
|
||||
sequenceId,
|
||||
className,
|
||||
onNavigate,
|
||||
nextHandler,
|
||||
previousHandler,
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
goToCourseExitPage,
|
||||
}) => {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
courseId,
|
||||
sequenceStatus,
|
||||
@@ -65,49 +63,27 @@ 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={nextHandler}
|
||||
disabled={disabled}
|
||||
iconAfter={nextArrow}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
|
||||
{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 }}>
|
||||
{renderPreviousButton()}
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderUnitButtons()}
|
||||
{renderNextButton()}
|
||||
|
||||
@@ -121,8 +97,9 @@ SequenceNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
nextHandler: PropTypes.func.isRequired,
|
||||
previousHandler: PropTypes.func.isRequired,
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
|
||||
@@ -25,21 +25,22 @@ describe('Sequence Navigation', () => {
|
||||
mockData = {
|
||||
unitId: unitBlocks[1].id,
|
||||
sequenceId: courseware.sequenceId,
|
||||
previousHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
onNavigate: () => {},
|
||||
nextHandler: () => {},
|
||||
nextSequenceHandler: () => {},
|
||||
goToCourseExitPage: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('is empty while loading', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
|
||||
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders empty div without unitId', () => {
|
||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
|
||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
|
||||
expect(getByText(container, (content, element) => (
|
||||
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -61,7 +62,7 @@ describe('Sequence Navigation', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
onNavigate: jest.fn(),
|
||||
};
|
||||
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SequenceNavigation {...testData} />, { store: testStore });
|
||||
|
||||
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
|
||||
fireEvent.click(unitButton);
|
||||
@@ -74,27 +75,27 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
it('renders correctly and handles unit button clicks', () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
|
||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
|
||||
|
||||
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
|
||||
const unitButtons = screen.getAllByRole('button', { 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} />, { wrapWithRouter: true });
|
||||
render(<SequenceNavigation {...mockData} />);
|
||||
|
||||
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
|
||||
screen.getAllByRole('button', { 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} />, { wrapWithRouter: true });
|
||||
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
||||
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
|
||||
@@ -106,10 +107,10 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
render(
|
||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -122,11 +123,11 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
render(
|
||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||
@@ -143,22 +144,22 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
render(
|
||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('handles "Previous" and "Next" click', () => {
|
||||
const previousHandler = jest.fn();
|
||||
const nextHandler = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
|
||||
const previousSequenceHandler = jest.fn();
|
||||
const nextSequenceHandler = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(previousHandler).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
expect(nextHandler).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,17 +40,14 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
|
||||
unitBlocks.forEach((unit, index) => {
|
||||
it(`marks unit ${index + 1} as active`, async () => {
|
||||
const { container } = render(
|
||||
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
|
||||
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, 'link', { hidden: true }).forEach(button => {
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === unit.display_name) {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
@@ -62,17 +59,14 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
|
||||
it('handles the clicks', () => {
|
||||
const onNavigate = jest.fn();
|
||||
const { container } = render(
|
||||
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
act(() => {
|
||||
fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
|
||||
unitBlocks.forEach((unit, index) => {
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);
|
||||
|
||||
@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
|
||||
|
||||
it('renders unit buttons', () => {
|
||||
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
|
||||
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||
render(<SequenceNavigationTabs {...mockData} />);
|
||||
|
||||
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
|
||||
expect(screen.getAllByRole('button')).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} />, { wrapWithRouter: true });
|
||||
const booyah = render(<SequenceNavigationTabs {...mockData} />);
|
||||
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, 'link');
|
||||
expect(dropdownButtons).toHaveLength(unitBlocks.length);
|
||||
const dropdownButtons = getAllByRole(dropdownMenu, 'button');
|
||||
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
|
||||
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
|
||||
.toHaveClass('dropdown-toggle');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { connect } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
@@ -21,8 +20,6 @@ const UnitButton = ({
|
||||
className,
|
||||
showTitle,
|
||||
}) => {
|
||||
const { courseId, sequenceId } = useSelector(state => state.courseware);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(unitId);
|
||||
}, [onClick, unitId]);
|
||||
@@ -36,8 +33,6 @@ 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>}
|
||||
|
||||
@@ -32,13 +32,13 @@ describe('Unit Button', () => {
|
||||
});
|
||||
|
||||
it('hides title by default', () => {
|
||||
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
|
||||
render(<UnitButton {...mockData} />);
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
|
||||
});
|
||||
|
||||
it('shows title', () => {
|
||||
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
|
||||
render(<UnitButton {...mockData} showTitle />);
|
||||
expect(screen.getByRole('button')).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} />, { wrapWithRouter: true });
|
||||
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
|
||||
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} />, { wrapWithRouter: true });
|
||||
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
|
||||
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} />, { wrapWithRouter: true });
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
render(<UnitButton {...mockData} onClick={onClick} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -21,32 +20,14 @@ const UnitNavigation = ({
|
||||
unitId,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
goToCourseExitPage,
|
||||
}) => {
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const { isFirstUnit, isLastUnit } = 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;
|
||||
@@ -54,10 +35,8 @@ const UnitNavigation = ({
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="next-button d-flex align-items-center justify-content-center"
|
||||
onClick={onClickNext}
|
||||
onClick={buttonOnClick}
|
||||
disabled={disabled}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
|
||||
{buttonText}
|
||||
@@ -67,9 +46,18 @@ const UnitNavigation = ({
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
|
||||
return (
|
||||
<div className="unit-navigation d-flex">
|
||||
{renderPreviousButton()}
|
||||
<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>
|
||||
{renderNextButton()}
|
||||
</div>
|
||||
);
|
||||
@@ -81,6 +69,7 @@ UnitNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
onClickPrevious: PropTypes.func.isRequired,
|
||||
onClickNext: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UnitNavigation.defaultProps = {
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('Unit Navigation', () => {
|
||||
sequenceId: courseware.sequenceId,
|
||||
onClickPrevious: () => {},
|
||||
onClickNext: () => {},
|
||||
goToCourseExitPage: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,10 +33,10 @@ describe('Unit Navigation', () => {
|
||||
unitId=""
|
||||
onClickPrevious={() => {}}
|
||||
onClickNext={() => {}}
|
||||
/>, { wrapWithRouter: true });
|
||||
/>);
|
||||
|
||||
// Only "Previous" and "Next" buttons should be rendered.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles the clicks', () => {
|
||||
@@ -44,30 +45,32 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(<UnitNavigation
|
||||
{...mockData}
|
||||
sequenceId=""
|
||||
unitId=""
|
||||
onClickPrevious={onClickPrevious}
|
||||
onClickNext={onClickNext}
|
||||
/>, { wrapWithRouter: true });
|
||||
/>);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
expect(onClickPrevious).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
expect(onClickNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
|
||||
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
|
||||
render(<UnitNavigation {...mockData} />);
|
||||
|
||||
screen.getAllByRole('link').forEach(button => {
|
||||
screen.getAllByRole('button').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} />, { wrapWithRouter: true });
|
||||
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
||||
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
|
||||
@@ -79,10 +82,10 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -95,11 +98,11 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||
@@ -116,10 +119,10 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ 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);
|
||||
|
||||
@@ -15,43 +14,12 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
||||
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
|
||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||
|
||||
const isFirstSequence = sequenceIndex === 0;
|
||||
const isFirstUnitInSequence = unitIndex === 0;
|
||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
||||
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
|
||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
|
||||
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
|
||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||
|
||||
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,
|
||||
};
|
||||
return { isFirstUnit, isLastUnit };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import React, {
|
||||
} from 'react';
|
||||
|
||||
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
||||
import { getSessionStorage } from '../../../data/sessionStorage';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
@@ -13,18 +15,32 @@ const SidebarProvider = ({
|
||||
unitId,
|
||||
children,
|
||||
}) => {
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
|
||||
if (query.get('sidebar') === 'true') {
|
||||
localStorage.setItem('showDiscussionSidebar', true);
|
||||
}
|
||||
const showDiscussionSidebar = localStorage.getItem('showDiscussionSidebar') !== 'false';
|
||||
const showNotificationSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
|
||||
? SIDEBARS.NOTIFICATIONS.ID
|
||||
: null;
|
||||
const initialSidebar = showDiscussionSidebar
|
||||
? SIDEBARS.DISCUSSIONS.ID
|
||||
: showNotificationSidebar;
|
||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID);
|
||||
// As a one-off set initial sidebar if the verified mode data has just loaded
|
||||
if (verifiedMode && currentSidebar === null && initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [unitId]);
|
||||
}, [initialSidebar, verifiedMode]);
|
||||
|
||||
const onNotificationSeen = useCallback(() => {
|
||||
setNotificationStatus('inactive');
|
||||
@@ -33,6 +49,11 @@ const SidebarProvider = ({
|
||||
|
||||
const toggleSidebar = useCallback((sidebarId) => {
|
||||
// Switch to new sidebar or hide the current sidebar
|
||||
if (currentSidebar === SIDEBARS.DISCUSSIONS.ID) {
|
||||
localStorage.setItem('showDiscussionSidebar', false);
|
||||
} else if (sidebarId === SIDEBARS.DISCUSSIONS.ID) {
|
||||
localStorage.setItem('showDiscussionSidebar', true);
|
||||
}
|
||||
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
|
||||
}, [currentSidebar]);
|
||||
|
||||
|
||||
@@ -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, useMemo } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
||||
@@ -23,16 +23,12 @@ 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(() => {
|
||||
if (baseUrl && edxProvider) {
|
||||
// Only fetch the topic data if the MFE is configured.
|
||||
if (baseUrl) {
|
||||
dispatch(getCourseDiscussionTopics(courseId));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -57,5 +57,4 @@ Factory.define('courseMetadata')
|
||||
related_programs: null,
|
||||
user_needs_integrity_signature: false,
|
||||
recommendations: null,
|
||||
learning_assistant_enabled: null,
|
||||
});
|
||||
|
||||
@@ -122,7 +122,6 @@ function normalizeMetadata(metadata) {
|
||||
relatedPrograms: camelCaseObject(data.related_programs),
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
canAccessProctoredExams: data.can_access_proctored_exams,
|
||||
learningAssistantEnabled: data.learning_assistant_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||
|
||||
import {
|
||||
getCourseMetadata,
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
|
||||
const {
|
||||
somethingLike: like, term, boolean, string, eachLike, integer,
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
|
||||
@@ -33,13 +33,37 @@ const provider = new PactV3({
|
||||
describe('Courseware Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, 'Custom app config for pact tests');
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
|
||||
describe('When a request to get a learning sequence outline is made', () => {
|
||||
it('returns a normalized outline', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Outline exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: {
|
||||
sections: [],
|
||||
sequences: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedOutline = {
|
||||
courses: {
|
||||
'course-v1:edX+DemoX+Demo_Course': {
|
||||
@@ -52,32 +76,74 @@ describe('Courseware Service', () => {
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Outline exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: {
|
||||
sections: [],
|
||||
sequences: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
}, 100);
|
||||
const response = await getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
});
|
||||
|
||||
it('skips unreleased sequences', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: like({
|
||||
sections: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially released',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
],
|
||||
effective_start: null,
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
|
||||
title: 'Wholly unreleased',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
],
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
],
|
||||
sequences: {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
title: 'Can access',
|
||||
accessible: true,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
title: 'Released and inaccessible',
|
||||
accessible: false,
|
||||
effective_start: '2019-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
title: 'Unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
title: 'Still unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedOutline = {
|
||||
courses: {
|
||||
'course-v1:edX+DemoX+Demo_Course': {
|
||||
@@ -113,78 +179,120 @@ describe('Courseware Service', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: like({
|
||||
sections: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially released',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
],
|
||||
effective_start: null,
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
|
||||
title: 'Wholly unreleased',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
],
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
],
|
||||
sequences: {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
title: 'Can access',
|
||||
accessible: true,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
title: 'Released and inaccessible',
|
||||
accessible: false,
|
||||
effective_start: '2019-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
title: 'Unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
title: 'Still unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
}, 100);
|
||||
const response = await getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get course metadata is made', () => {
|
||||
it('returns normalized course metadata', () => {
|
||||
it('returns normalized course metadata', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `course metadata exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get course metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/course/${courseId}`,
|
||||
query: {
|
||||
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
access_expiration: {
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
masquerading_expired_course: boolean(false),
|
||||
upgrade_deadline: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
upgrade_url: string('link'),
|
||||
},
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
content_type_gating_enabled: boolean(false),
|
||||
end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment: {
|
||||
mode: term({
|
||||
generate: 'audit',
|
||||
matcher: '^(audit|verified)$',
|
||||
}),
|
||||
is_active: boolean(true),
|
||||
},
|
||||
enrollment_start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment_end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
license: string('all-rights-reserved'),
|
||||
name: like('Demonstration Course'),
|
||||
offer: {
|
||||
code: string('code'),
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
original_price: string('$99'),
|
||||
discounted_price: string('$99'),
|
||||
percentage: integer(50),
|
||||
upgrade_url: string('url'),
|
||||
},
|
||||
related_programs: null,
|
||||
short_description: like(''),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
user_timezone: null,
|
||||
verified_mode: like({
|
||||
access_expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
show_calculator: boolean(false),
|
||||
original_user_is_staff: boolean(true),
|
||||
is_staff: boolean(true),
|
||||
course_access: like({
|
||||
has_access: true,
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
}),
|
||||
notes: { enabled: boolean(false), visible: boolean(true) },
|
||||
marketing_url: null,
|
||||
user_has_passing_grade: boolean(false),
|
||||
course_exit_page_is_active: boolean(false),
|
||||
certificate_data: {
|
||||
cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
|
||||
},
|
||||
verify_identity_url: null,
|
||||
verification_status: string('none'),
|
||||
linkedin_add_to_profile_url: null,
|
||||
user_needs_integrity_signature: boolean(false),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedCourseMetadata = {
|
||||
accessExpiration: {
|
||||
expirationDate: '2013-02-05T05:00:00Z',
|
||||
@@ -228,125 +336,57 @@ describe('Courseware Service', () => {
|
||||
linkedinAddToProfileUrl: null,
|
||||
relatedPrograms: null,
|
||||
userNeedsIntegritySignature: false,
|
||||
learningAssistantEnabled: false,
|
||||
};
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `course metadata exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get course metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/course/${courseId}`,
|
||||
query: {
|
||||
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
access_expiration: {
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
masquerading_expired_course: boolean(false),
|
||||
upgrade_deadline: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
upgrade_url: string('link'),
|
||||
},
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
content_type_gating_enabled: boolean(false),
|
||||
end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment: {
|
||||
mode: term({
|
||||
generate: 'audit',
|
||||
matcher: '^(audit|verified)$',
|
||||
}),
|
||||
is_active: boolean(true),
|
||||
},
|
||||
enrollment_start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment_end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
license: string('all-rights-reserved'),
|
||||
name: like('Demonstration Course'),
|
||||
offer: {
|
||||
code: string('code'),
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
original_price: string('$99'),
|
||||
discounted_price: string('$99'),
|
||||
percentage: integer(50),
|
||||
upgrade_url: string('url'),
|
||||
},
|
||||
related_programs: null,
|
||||
short_description: like(''),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
user_timezone: null,
|
||||
verified_mode: like({
|
||||
access_expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
show_calculator: boolean(false),
|
||||
original_user_is_staff: boolean(true),
|
||||
is_staff: boolean(true),
|
||||
course_access: like({
|
||||
has_access: true,
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
}),
|
||||
notes: { enabled: boolean(false), visible: boolean(true) },
|
||||
marketing_url: null,
|
||||
user_has_passing_grade: boolean(false),
|
||||
course_exit_page_is_active: boolean(false),
|
||||
certificate_data: {
|
||||
cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
|
||||
},
|
||||
verify_identity_url: null,
|
||||
verification_status: string('none'),
|
||||
linkedin_add_to_profile_url: null,
|
||||
user_needs_integrity_signature: boolean(false),
|
||||
learning_assistant_enabled: boolean(false),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getCourseMetadata(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedCourseMetadata);
|
||||
}, 100);
|
||||
const response = await getCourseMetadata(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedCourseMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get sequence metadata is made', () => {
|
||||
it('returns normalized sequence metadata ', () => {
|
||||
it('returns normalized sequence metadata ', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
|
||||
uponReceiving: 'a request to get sequence metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/sequence/${sequenceId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
items: eachLike({
|
||||
content: '',
|
||||
page_title: 'Pointing on a Picture',
|
||||
type: 'problem',
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
bookmarked: false,
|
||||
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
|
||||
graded: true,
|
||||
contains_content_type_gated_content: false,
|
||||
href: '',
|
||||
}),
|
||||
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
|
||||
is_time_limited: boolean(false),
|
||||
is_proctored: boolean(false),
|
||||
is_hidden_after_due: boolean(false),
|
||||
position: null,
|
||||
tag: boolean('sequential'),
|
||||
banner_text: null,
|
||||
save_position: boolean(false),
|
||||
show_completion: boolean(false),
|
||||
gated_content: like({
|
||||
prereq_id: null,
|
||||
prereq_url: null,
|
||||
prereq_section_name: null,
|
||||
gated: false,
|
||||
gated_section_name: 'Homework - Question Styles',
|
||||
}),
|
||||
display_name: boolean('Homework - Question Styles'),
|
||||
format: boolean('Homework'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedSequenceMetadata = {
|
||||
sequence: {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||
@@ -383,154 +423,102 @@ describe('Courseware Service', () => {
|
||||
containsContentTypeGatedContent: false,
|
||||
}],
|
||||
};
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
|
||||
uponReceiving: 'a request to get sequence metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/sequence/${sequenceId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
items: eachLike({
|
||||
content: '',
|
||||
page_title: 'Pointing on a Picture',
|
||||
type: 'problem',
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
bookmarked: false,
|
||||
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
|
||||
graded: true,
|
||||
contains_content_type_gated_content: false,
|
||||
href: '',
|
||||
}),
|
||||
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
|
||||
is_time_limited: boolean(false),
|
||||
is_proctored: boolean(false),
|
||||
is_hidden_after_due: boolean(false),
|
||||
position: null,
|
||||
tag: boolean('sequential'),
|
||||
banner_text: null,
|
||||
save_position: boolean(false),
|
||||
show_completion: boolean(false),
|
||||
gated_content: like({
|
||||
prereq_id: null,
|
||||
prereq_url: null,
|
||||
prereq_section_name: null,
|
||||
gated: false,
|
||||
gated_section_name: 'Homework - Question Styles',
|
||||
}),
|
||||
display_name: boolean('Homework - Question Styles'),
|
||||
format: boolean('Homework'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getSequenceMetadata(sequenceId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedSequenceMetadata);
|
||||
}, 100);
|
||||
const response = await getSequenceMetadata(sequenceId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedSequenceMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to set sequence position against Unit Index is made', () => {
|
||||
it('returns if the request was success or failure', async () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
||||
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
|
||||
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
|
||||
await provider.addInteraction({
|
||||
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
||||
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
|
||||
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
success: boolean(true),
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
success: boolean(true),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = postSequencePosition(courseId, sequenceId, 0);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual({ success: true });
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
const response = await postSequencePosition(courseId, sequenceId, 0);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get completion block is made', () => {
|
||||
it('returns the completion status', async () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
||||
uponReceiving: 'a request to get completion block',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
||||
body: { usage_key: usageId },
|
||||
await provider.addInteraction({
|
||||
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
||||
uponReceiving: 'a request to get completion block',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
||||
body: { usage_key: usageId },
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
complete: boolean(true),
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
complete: boolean(true),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getBlockCompletion(courseId, sequenceId, usageId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(true);
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
const response = await getBlockCompletion(courseId, sequenceId, usageId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get resume block is made', () => {
|
||||
it('returns block id, section id and unit id of the resume block', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Resume block exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get Resume block',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/resume/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
block_id: string('642fadf46d074aabb637f20af320fb31'),
|
||||
section_id: string('642fadf46d074aabb637f20af320fb87'),
|
||||
unit_id: string('642fadf46d074aabb637f20af320fb99'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
blockId: '642fadf46d074aabb637f20af320fb31',
|
||||
sectionId: '642fadf46d074aabb637f20af320fb87',
|
||||
unitId: '642fadf46d074aabb637f20af320fb99',
|
||||
};
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Resume block exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get Resume block',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/resume/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
block_id: string('642fadf46d074aabb637f20af320fb31'),
|
||||
section_id: string('642fadf46d074aabb637f20af320fb87'),
|
||||
unit_id: string('642fadf46d074aabb637f20af320fb99'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getResumeBlock(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
const response = await getResumeBlock(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to send activation email is made', () => {
|
||||
it('returns status code 200', () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: 'A logged-in user may or may not be active',
|
||||
uponReceiving: 'a request to send activation email',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: '/api/send_account_activation_email',
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const response = sendActivationEmail();
|
||||
expect(response).toEqual('');
|
||||
}, 100);
|
||||
it('returns status code 200', async () => {
|
||||
await provider.addInteraction({
|
||||
state: 'A logged-in user may or may not be active',
|
||||
uponReceiving: 'a request to send activation email',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: '/api/send_account_activation_email',
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const response = await sendActivationEmail();
|
||||
expect(response).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
||||
<div>
|
||||
PageWrap: {
|
||||
"children": [
|
||||
" ",
|
||||
[
|
||||
" ",
|
||||
[],
|
||||
" "
|
||||
],
|
||||
" "
|
||||
]
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
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])),
|
||||
);
|
||||
import { useHistory, generatePath } from 'react-router';
|
||||
|
||||
export const decodeUrl = (encodedUrl) => {
|
||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||
@@ -19,16 +11,10 @@ export const decodeUrl = (encodedUrl) => {
|
||||
return decodeUrl(decodedUrl);
|
||||
};
|
||||
|
||||
const DecodePageRoute = ({ children }) => {
|
||||
let computedMatch = null;
|
||||
|
||||
ROUTES.forEach((route) => {
|
||||
const matchedRoute = useMatch(route);
|
||||
if (matchedRoute) { computedMatch = matchedRoute; }
|
||||
});
|
||||
|
||||
if (computedMatch) {
|
||||
const { pathname, pattern, params } = computedMatch;
|
||||
const DecodePageRoute = (props) => {
|
||||
const history = useHistory();
|
||||
if (props.computedMatch) {
|
||||
const { url, path, params } = props.computedMatch;
|
||||
|
||||
Object.keys(params).forEach((param) => {
|
||||
// only decode params not the entire url.
|
||||
@@ -36,19 +22,28 @@ const DecodePageRoute = ({ children }) => {
|
||||
params[param] = decodeUrl(params[param]);
|
||||
});
|
||||
|
||||
const newUrl = generatePath(pattern.path, params);
|
||||
const newUrl = generatePath(path, params);
|
||||
|
||||
// if the url get decoded, reroute to the decoded url
|
||||
if (newUrl !== pathname) {
|
||||
return <Navigate to={newUrl} replace />;
|
||||
if (newUrl !== url) {
|
||||
history.replace(newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return <PageWrap> {children} </PageWrap>;
|
||||
return <PageRoute {...props} />;
|
||||
};
|
||||
|
||||
DecodePageRoute.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
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,
|
||||
};
|
||||
|
||||
export default DecodePageRoute;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router, matchPath } from 'react-router';
|
||||
import DecodePageRoute, { decodeUrl } from '.';
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
@@ -16,90 +15,84 @@ const deepEncodedCourseId = (() => {
|
||||
})();
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
PageWrap: (props) => `PageWrap: ${JSON.stringify(props, null, 2)}`,
|
||||
PageRoute: (props) => `PageRoute: ${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`,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const mockNavigation = 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 memHistory = createMemoryHistory({
|
||||
initialEntries: [props?.path],
|
||||
});
|
||||
|
||||
const history = {
|
||||
...memHistory,
|
||||
replace: jest.fn(),
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<Router initialEntries={[props?.pathname]}>
|
||||
<Routes>
|
||||
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
|
||||
</Routes>
|
||||
<Router history={history}>
|
||||
<DecodePageRoute computedMatch={props} />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
return { container };
|
||||
return {
|
||||
container,
|
||||
history,
|
||||
props,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DecodePageRoute', () => {
|
||||
afterEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('should not modify the url if it does not need to be decoded', () => {
|
||||
const props = matchPath({
|
||||
const props = matchPath(`/course/${decodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
}, `/course/${decodedCourseId}/home`);
|
||||
const { container } = renderPage(props);
|
||||
});
|
||||
const { container, history } = renderPage(props);
|
||||
|
||||
expect(props.pathname).toContain(decodedCourseId);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(props.url).toContain(decodedCourseId);
|
||||
expect(history.replace).not.toHaveBeenCalled();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should decode the url and replace the history if necessary', () => {
|
||||
const props = matchPath({
|
||||
const props = matchPath(`/course/${encodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
}, `/course/${encodedCourseId}/home`);
|
||||
renderPage(props);
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
|
||||
expect(props.pathname).not.toContain(decodedCourseId);
|
||||
expect(props.pathname).toContain(encodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(encodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
});
|
||||
|
||||
it('should decode the url multiple times if necessary', () => {
|
||||
const props = matchPath({
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
}, `/course/${deepEncodedCourseId}/home`);
|
||||
renderPage(props);
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
|
||||
expect(props.pathname).not.toContain(decodedCourseId);
|
||||
expect(props.pathname).toContain(deepEncodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(deepEncodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
});
|
||||
|
||||
it('should only decode the url params and not the entire url', () => {
|
||||
const decodedUnitId = 'some+thing';
|
||||
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
||||
const props = matchPath({
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
|
||||
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
||||
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
|
||||
renderPage(props);
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { useParams } 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';
|
||||
@@ -37,7 +38,7 @@ const CourseAccessErrorPage = ({ intl }) => {
|
||||
);
|
||||
}
|
||||
if (courseStatus === LOADED) {
|
||||
return <Navigate to={`/redirect/home/${courseId}`} replace />;
|
||||
return (<Redirect to={`/redirect/home/${courseId}`} />);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Route } from 'react-router';
|
||||
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,
|
||||
@@ -16,10 +14,6 @@ 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;
|
||||
@@ -34,36 +28,33 @@ describe('CourseAccessErrorPage', () => {
|
||||
it('Displays loading in start on page rendering', () => {
|
||||
mockCourseStatus = 'loading';
|
||||
render(
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
);
|
||||
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
||||
});
|
||||
|
||||
it('Redirect user to homepage if user has access', () => {
|
||||
mockCourseStatus = 'loaded';
|
||||
render(
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
);
|
||||
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||
});
|
||||
|
||||
it('For access denied it should render access denied page', () => {
|
||||
mockCourseStatus = 'denied';
|
||||
|
||||
render(
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
);
|
||||
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Redirect, 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(' ') || location.pathname.includes('%20')) {
|
||||
if (location.pathname.includes(' ')) {
|
||||
const newLocation = {
|
||||
...location,
|
||||
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
|
||||
pathname: location.pathname.replaceAll(' ', '+'),
|
||||
};
|
||||
|
||||
sendTrackEvent('edx.ui.lms.path_fixed', {
|
||||
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
|
||||
search: location.search,
|
||||
});
|
||||
|
||||
return (<Navigate to={newLocation} replace />);
|
||||
return (<Redirect to={newLocation} />);
|
||||
}
|
||||
|
||||
return children; // pass through
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user