Compare commits
71 Commits
bw/hackath
...
oliv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24568f0bd | ||
|
|
5604def491 | ||
|
|
b788b969c3 | ||
|
|
b7a3d5640a | ||
|
|
3a21d8c807 | ||
|
|
81442bebe9 | ||
|
|
168ed1e184 | ||
|
|
c8e32c3f46 | ||
|
|
51dd90741b | ||
|
|
f58d6d6d25 | ||
|
|
81a49bd755 | ||
|
|
2ae033160f | ||
|
|
32bd3190a6 | ||
|
|
645ac2cb5f | ||
|
|
ee80b24cba | ||
|
|
ee1d816cc8 | ||
|
|
e8ac2ffc7e | ||
|
|
62d3e95cc8 | ||
|
|
ce6771d7cc | ||
|
|
1dcde821b4 | ||
|
|
694e3ed6d5 | ||
|
|
ba843622c2 | ||
|
|
2d29827e6b | ||
|
|
2b9b3db5d3 | ||
|
|
2e90e214b4 | ||
|
|
ea2d7ed839 | ||
|
|
5ee61904d5 | ||
|
|
6232b0cb98 | ||
|
|
09542338a2 | ||
|
|
c3d345e642 | ||
|
|
ec2bf60345 | ||
|
|
b0c71e5291 | ||
|
|
dcd6847254 | ||
|
|
d2df9241c3 | ||
|
|
1871e491a7 | ||
|
|
03543c0af1 | ||
|
|
0c49658314 | ||
|
|
2a1173584e | ||
|
|
398330fa07 | ||
|
|
f92fc8c3a5 | ||
|
|
5e072949d6 | ||
|
|
2d132f114c | ||
|
|
c73ef26d8e | ||
|
|
97ca7fe6aa | ||
|
|
e95a59c6c8 | ||
|
|
5f9c441cd2 | ||
|
|
2e641ac6c9 | ||
|
|
22937918ab | ||
|
|
714f5d452c | ||
|
|
8ac9745261 | ||
|
|
340580cb41 | ||
|
|
5a99ca5c91 | ||
|
|
9943df49e4 | ||
|
|
855474d406 | ||
|
|
a78496a3f6 | ||
|
|
79b65dadca | ||
|
|
fc8f5d43e8 | ||
|
|
6232f40a74 | ||
|
|
bc0ff1ce65 | ||
|
|
5997b29cee | ||
|
|
d2de0632cd | ||
|
|
922cc2187a | ||
|
|
d9539796b5 | ||
|
|
e0acb501eb | ||
|
|
a03ffe2724 | ||
|
|
cbdf7ce064 | ||
|
|
7184e85b2b | ||
|
|
b5321d01e4 | ||
|
|
6c8ab1a4c9 | ||
|
|
01f9d8f50b | ||
|
|
764befd4bd |
1
.env
1
.env
@@ -46,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
|
|||||||
TWITTER_HASHTAG=''
|
TWITTER_HASHTAG=''
|
||||||
TWITTER_URL=''
|
TWITTER_URL=''
|
||||||
USER_INFO_COOKIE_NAME=''
|
USER_INFO_COOKIE_NAME=''
|
||||||
|
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
|||||||
ENABLE_JUMPNAV='true'
|
ENABLE_JUMPNAV='true'
|
||||||
ENABLE_NOTICES=''
|
ENABLE_NOTICES=''
|
||||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||||
EXAMS_BASE_URL='http://localhost:18740'
|
EXAMS_BASE_URL=''
|
||||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
IGNORED_ERROR_REGEX=''
|
IGNORED_ERROR_REGEX=''
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||||
@@ -46,3 +46,6 @@ TWITTER_HASHTAG='myedxjourney'
|
|||||||
TWITTER_URL='https://twitter.com/edXOnline'
|
TWITTER_URL='https://twitter.com/edXOnline'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
SESSION_COOKIE_DOMAIN='localhost'
|
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,3 +45,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
|||||||
TWITTER_HASHTAG='myedxjourney'
|
TWITTER_HASHTAG='myedxjourney'
|
||||||
TWITTER_URL='https://twitter.com/edXOnline'
|
TWITTER_URL='https://twitter.com/edXOnline'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
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:
|
jobs:
|
||||||
version-check:
|
version-check:
|
||||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||||
|
|||||||
7
.github/workflows/validate.yml
vendored
7
.github/workflows/validate.yml
vendored
@@ -9,14 +9,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node: [16]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Nodejs Env
|
||||||
|
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: ${{ env.NODE_VER }}
|
||||||
- run: make validate.ci
|
- run: make validate.ci
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.idea
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -1,6 +1,7 @@
|
|||||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
|
||||||
|
|
||||||
|
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
i18n = ./src/i18n
|
i18n = ./src/i18n
|
||||||
transifex_input = $(i18n)/transifex_input.json
|
transifex_input = $(i18n)/transifex_input.json
|
||||||
@@ -42,9 +43,24 @@ push_translations:
|
|||||||
# Pushing comments to Transifex...
|
# Pushing comments to Transifex...
|
||||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||||
|
|
||||||
|
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||||
# Pulls translations from Transifex.
|
# Pulls translations from Transifex.
|
||||||
pull_translations:
|
pull_translations:
|
||||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
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.
|
# This target is used by Travis.
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ module.exports = createConfig('jest', {
|
|||||||
'src/i18n',
|
'src/i18n',
|
||||||
'src/.*\\.exp\\..*',
|
'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,
|
testTimeout: 30000,
|
||||||
testEnvironment: 'jsdom'
|
testEnvironment: 'jsdom'
|
||||||
});
|
});
|
||||||
|
|||||||
44696
package-lock.json
generated
44696
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -30,43 +30,45 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||||
"@edx/frontend-component-footer": "11.6.3",
|
"@edx/frontend-component-footer": "12.2.1",
|
||||||
"@edx/frontend-component-header": "3.6.4",
|
"@edx/frontend-component-header": "4.6.0",
|
||||||
"@edx/frontend-lib-special-exams": "2.10.0",
|
"@edx/frontend-lib-learning-assistant": "^1.14.0",
|
||||||
"@edx/frontend-platform": "3.4.1",
|
"@edx/frontend-lib-special-exams": "2.23.2",
|
||||||
"@edx/paragon": "20.28.4",
|
"@edx/frontend-platform": "5.5.2",
|
||||||
|
"@edx/paragon": "20.46.0",
|
||||||
|
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||||
"@fortawesome/react-fontawesome": "0.1.18",
|
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||||
"@popperjs/core": "2.11.6",
|
"@popperjs/core": "2.11.8",
|
||||||
"@reduxjs/toolkit": "1.8.1",
|
"@reduxjs/toolkit": "1.8.1",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"core-js": "3.22.2",
|
"core-js": "3.22.2",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"js-cookie": "3.0.1",
|
"js-cookie": "3.0.5",
|
||||||
"lodash.camelcase": "4.3.0",
|
"lodash.camelcase": "4.3.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"query-string": "7.1.3",
|
"query-string": "^7.1.3",
|
||||||
"react": "16.14.0",
|
"react": "17.0.2",
|
||||||
"react-dom": "16.14.0",
|
"react-dom": "17.0.2",
|
||||||
"react-helmet": "6.1.0",
|
"react-helmet": "6.1.0",
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.9",
|
||||||
"react-router": "5.2.1",
|
"react-router": "6.15.0",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "6.15.0",
|
||||||
"react-share": "4.4.1",
|
"react-share": "4.4.1",
|
||||||
"redux": "4.1.2",
|
"redux": "4.1.2",
|
||||||
"regenerator-runtime": "0.13.11",
|
"regenerator-runtime": "0.13.11",
|
||||||
"reselect": "4.1.7",
|
"reselect": "4.1.8",
|
||||||
"truncate-html": "1.0.4",
|
"truncate-html": "1.0.4",
|
||||||
"util": "0.12.5"
|
"util": "0.12.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.1.1",
|
"@edx/browserslist-config": "1.2.0",
|
||||||
"@edx/frontend-build": "^12.4.15",
|
"@edx/frontend-build": "^12.9.10",
|
||||||
"@edx/reactifex": "2.1.1",
|
"@edx/reactifex": "2.2.0",
|
||||||
"@pact-foundation/pact": "9.17.3",
|
"@pact-foundation/pact": "^11.0.2",
|
||||||
"@testing-library/jest-dom": "5.16.5",
|
"@testing-library/jest-dom": "5.16.5",
|
||||||
"@testing-library/react": "12.1.5",
|
"@testing-library/react": "12.1.5",
|
||||||
"@testing-library/user-event": "13.5.0",
|
"@testing-library/user-event": "13.5.0",
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
"copy-webpack-plugin": "^11.0.0",
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"es-check": "6.2.1",
|
"es-check": "6.2.1",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest": "27.5.1",
|
"jest": "29.5.0",
|
||||||
"rosie": "2.1.0"
|
"rosie": "2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
|
|||||||
<Alert variant="info" icon={Info}>
|
<Alert variant="info" icon={Info}>
|
||||||
<strong>
|
<strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="learning.outline.alert.end.long"
|
id="learning.outline.alert.start.long"
|
||||||
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
||||||
description="Used when the time remaining is more than a day away."
|
description="Used when the time remaining is more than a day away."
|
||||||
values={{
|
values={{
|
||||||
@@ -88,7 +88,7 @@ const CourseStartAlert = ({ payload }) => {
|
|||||||
</strong>
|
</strong>
|
||||||
<br />
|
<br />
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="learning.outline.alert.end.calendar"
|
id="learning.outline.alert.start.calendar"
|
||||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
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. "
|
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
|
||||||
/>
|
/>
|
||||||
|
|||||||
33
src/constants.js
Normal file
33
src/constants.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const DECODE_ROUTES = {
|
||||||
|
ACCESS_DENIED: '/course/:courseId/access-denied',
|
||||||
|
HOME: '/course/:courseId/home',
|
||||||
|
LIVE: '/course/:courseId/live',
|
||||||
|
DATES: '/course/:courseId/dates',
|
||||||
|
DISCUSSION: '/course/:courseId/discussion/:path/*',
|
||||||
|
PROGRESS: [
|
||||||
|
'/course/:courseId/progress/:targetUserId/',
|
||||||
|
'/course/:courseId/progress',
|
||||||
|
],
|
||||||
|
COURSE_END: '/course/:courseId/course-end',
|
||||||
|
COURSEWARE: [
|
||||||
|
'/course/:courseId/:sequenceId/:unitId',
|
||||||
|
'/course/:courseId/:sequenceId',
|
||||||
|
'/course/:courseId',
|
||||||
|
],
|
||||||
|
REDIRECT_HOME: 'home/:courseId',
|
||||||
|
REDIRECT_SURVEY: 'survey/:courseId',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROUTES = {
|
||||||
|
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||||
|
REDIRECT: '/redirect/*',
|
||||||
|
DASHBOARD: 'dashboard',
|
||||||
|
CONSENT: 'consent',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REDIRECT_MODES = {
|
||||||
|
DASHBOARD_REDIRECT: 'dashboard-redirect',
|
||||||
|
CONSENT_REDIRECT: 'consent-redirect',
|
||||||
|
HOME_REDIRECT: 'home-redirect',
|
||||||
|
SURVEY_REDIRECT: 'survey-redirect',
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ Factory.define('outlineTabData')
|
|||||||
upgrade_url: `${host}/dashboard`,
|
upgrade_url: `${host}/dashboard`,
|
||||||
}))
|
}))
|
||||||
.attrs({
|
.attrs({
|
||||||
|
course_access_redirect: false,
|
||||||
has_scheduled_content: null,
|
has_scheduled_content: null,
|
||||||
access_expiration: null,
|
access_expiration: null,
|
||||||
can_show_upgrade_sock: false,
|
can_show_upgrade_sock: false,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ Object {
|
|||||||
"sequenceMightBeUnit": false,
|
"sequenceMightBeUnit": false,
|
||||||
"sequenceStatus": "loading",
|
"sequenceStatus": "loading",
|
||||||
},
|
},
|
||||||
|
"learningAssistant": ObjectContaining {
|
||||||
|
"conversationId": Any<String>,
|
||||||
|
},
|
||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courseHomeMeta": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||||
@@ -336,6 +339,9 @@ Object {
|
|||||||
"sequenceMightBeUnit": false,
|
"sequenceMightBeUnit": false,
|
||||||
"sequenceStatus": "loading",
|
"sequenceStatus": "loading",
|
||||||
},
|
},
|
||||||
|
"learningAssistant": ObjectContaining {
|
||||||
|
"conversationId": Any<String>,
|
||||||
|
},
|
||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courseHomeMeta": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||||
@@ -532,6 +538,9 @@ Object {
|
|||||||
"sequenceMightBeUnit": false,
|
"sequenceMightBeUnit": false,
|
||||||
"sequenceStatus": "loading",
|
"sequenceStatus": "loading",
|
||||||
},
|
},
|
||||||
|
"learningAssistant": ObjectContaining {
|
||||||
|
"conversationId": Any<String>,
|
||||||
|
},
|
||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courseHomeMeta": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||||
|
|||||||
@@ -204,12 +204,18 @@ export async function getDatesTabData(courseId) {
|
|||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
return camelCaseObject(data);
|
return camelCaseObject(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { httpErrorStatus } = error && error.customAttributes;
|
const httpErrorStatus = error?.response?.status;
|
||||||
if (httpErrorStatus === 401) {
|
if (httpErrorStatus === 401) {
|
||||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
// 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.
|
// courseAccess in the metadata call, so just ignore this status for now.
|
||||||
return {};
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +265,7 @@ export async function getProgressTabData(courseId, targetUserId) {
|
|||||||
|
|
||||||
return camelCasedData;
|
return camelCasedData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { httpErrorStatus } = error && error.customAttributes;
|
const httpErrorStatus = error?.response?.status;
|
||||||
if (httpErrorStatus === 404) {
|
if (httpErrorStatus === 404) {
|
||||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||||
return {};
|
return {};
|
||||||
@@ -269,6 +275,12 @@ export async function getProgressTabData(courseId, targetUserId) {
|
|||||||
// courseAccess in the metadata call, so just ignore this status for now.
|
// courseAccess in the metadata call, so just ignore this status for now.
|
||||||
return {};
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,7 +334,20 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
|||||||
export async function getOutlineTabData(courseId) {
|
export async function getOutlineTabData(courseId) {
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||||
const requestTime = Date.now();
|
const requestTime = Date.now();
|
||||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
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 responseTime = Date.now();
|
const responseTime = Date.now();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||||
|
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCourseHomeCourseMetadata,
|
getCourseHomeCourseMetadata,
|
||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
somethingLike: like, term, boolean, string, eachLike,
|
somethingLike: like, term, boolean, string, eachLike,
|
||||||
} = Matchers;
|
} = MatchersV3;
|
||||||
const provider = new Pact({
|
const provider = new PactV3({
|
||||||
consumer: 'frontend-app-learning',
|
consumer: 'frontend-app-learning',
|
||||||
provider: 'lms',
|
provider: 'lms',
|
||||||
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
|
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
|
||||||
@@ -28,194 +28,193 @@ const provider = new Pact({
|
|||||||
describe('Course Home Service', () => {
|
describe('Course Home Service', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
initializeMockApp();
|
initializeMockApp();
|
||||||
await provider
|
mergeConfig({
|
||||||
.setup()
|
LMS_BASE_URL: 'http://localhost:8081',
|
||||||
.then((options) => mergeConfig({
|
}, 'Custom app config for pact tests');
|
||||||
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', () => {
|
describe('When a request to fetch tab is made', () => {
|
||||||
it('returns tab data for a course_id', async () => {
|
it('returns tab data for a course_id', async () => {
|
||||||
await provider.addInteraction({
|
setTimeout(() => {
|
||||||
state: `Tab data exists for course_id ${courseId}`,
|
provider.addInteraction({
|
||||||
uponReceiving: 'a request to fetch tab',
|
state: `Tab data exists for course_id ${courseId}`,
|
||||||
withRequest: {
|
uponReceiving: 'a request to fetch tab',
|
||||||
method: 'GET',
|
withRequest: {
|
||||||
path: `/api/course_home/course_metadata/${courseId}`,
|
method: 'GET',
|
||||||
},
|
path: `/api/course_home/course_metadata/${courseId}`,
|
||||||
willRespondWith: {
|
},
|
||||||
status: 200,
|
willRespondWith: {
|
||||||
body: {
|
status: 200,
|
||||||
can_show_upgrade_sock: boolean(false),
|
body: {
|
||||||
verified_mode: like({
|
can_show_upgrade_sock: boolean(false),
|
||||||
access_expiration_date: null,
|
verified_mode: like({
|
||||||
currency: 'USD',
|
access_expiration_date: null,
|
||||||
currency_symbol: '$',
|
currency: 'USD',
|
||||||
price: 149,
|
currency_symbol: '$',
|
||||||
sku: '8CF08E5',
|
price: 149,
|
||||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
sku: '8CF08E5',
|
||||||
}),
|
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
celebrations: like({
|
}),
|
||||||
first_section: false,
|
celebrations: like({
|
||||||
streak_length_to_celebrate: null,
|
first_section: false,
|
||||||
streak_discount_enabled: false,
|
streak_length_to_celebrate: null,
|
||||||
}),
|
streak_discount_enabled: false,
|
||||||
course_access: {
|
}),
|
||||||
has_access: boolean(true),
|
course_access: {
|
||||||
error_code: null,
|
has_access: boolean(true),
|
||||||
developer_message: null,
|
error_code: null,
|
||||||
user_message: null,
|
developer_message: null,
|
||||||
additional_context_user_message: null,
|
user_message: null,
|
||||||
user_fragment: 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'),
|
||||||
},
|
},
|
||||||
course_id: term({
|
},
|
||||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
});
|
||||||
matcher: opaqueKeysRegex,
|
const normalizedTabData = {
|
||||||
}),
|
canShowUpgradeSock: false,
|
||||||
is_enrolled: boolean(true),
|
verifiedMode: {
|
||||||
is_self_paced: boolean(false),
|
accessExpirationDate: null,
|
||||||
is_staff: boolean(true),
|
currency: 'USD',
|
||||||
number: string('DemoX'),
|
currencySymbol: '$',
|
||||||
org: string('edX'),
|
price: 149,
|
||||||
original_user_is_staff: boolean(true),
|
sku: '8CF08E5',
|
||||||
start: term({
|
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
generate: '2013-02-05T05:00:00Z',
|
},
|
||||||
matcher: dateRegex,
|
celebrations: {
|
||||||
}),
|
firstSection: false,
|
||||||
tabs: eachLike({
|
streakLengthToCelebrate: null,
|
||||||
tab_id: 'courseware',
|
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',
|
title: 'Course',
|
||||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||||
}),
|
},
|
||||||
title: string('Demonstration Course'),
|
],
|
||||||
username: string('edx'),
|
title: 'Demonstration Course',
|
||||||
},
|
username: 'edx',
|
||||||
},
|
};
|
||||||
});
|
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||||
const normalizedTabData = {
|
expect(response).toBeTruthy();
|
||||||
canShowUpgradeSock: false,
|
expect(response).toEqual(normalizedTabData);
|
||||||
verifiedMode: {
|
}, 100);
|
||||||
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', () => {
|
describe('When a request to fetch dates tab is made', () => {
|
||||||
it('returns course date blocks for a course_id', async () => {
|
it('returns course date blocks for a course_id', async () => {
|
||||||
await provider.addInteraction({
|
setTimeout(() => {
|
||||||
state: `course date blocks exist for course_id ${courseId}`,
|
provider.addInteraction({
|
||||||
uponReceiving: 'a request to fetch dates tab',
|
state: `course date blocks exist for course_id ${courseId}`,
|
||||||
withRequest: {
|
uponReceiving: 'a request to fetch dates tab',
|
||||||
method: 'GET',
|
withRequest: {
|
||||||
path: `/api/course_home/dates/${courseId}`,
|
method: 'GET',
|
||||||
},
|
path: `/api/course_home/dates/${courseId}`,
|
||||||
willRespondWith: {
|
},
|
||||||
status: 200,
|
willRespondWith: {
|
||||||
body: {
|
status: 200,
|
||||||
dates_banner_info: like({
|
body: {
|
||||||
missed_deadlines: false,
|
dates_banner_info: like({
|
||||||
content_type_gating_enabled: false,
|
missed_deadlines: false,
|
||||||
missed_gated_content: false,
|
content_type_gating_enabled: false,
|
||||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
missed_gated_content: false,
|
||||||
}),
|
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
course_date_blocks: eachLike({
|
}),
|
||||||
assignment_type: null,
|
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,
|
||||||
complete: null,
|
complete: null,
|
||||||
date: term({
|
date: '2013-02-05T05:00:00Z',
|
||||||
generate: '2013-02-05T05:00:00Z',
|
dateType: 'verified-upgrade-deadline',
|
||||||
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.',
|
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,
|
learnerHasAccess: true,
|
||||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
link_text: 'Upgrade to Verified Certificate',
|
linkText: 'Upgrade to Verified Certificate',
|
||||||
title: 'Verification Upgrade Deadline',
|
title: 'Verification Upgrade Deadline',
|
||||||
extra_info: null,
|
extraInfo: null,
|
||||||
first_component_block_id: '',
|
firstComponentBlockId: '',
|
||||||
}),
|
},
|
||||||
has_ended: boolean(false),
|
],
|
||||||
learner_is_full_access: boolean(true),
|
hasEnded: false,
|
||||||
user_timezone: null,
|
learnerIsFullAccess: true,
|
||||||
},
|
userTimezone: null,
|
||||||
},
|
};
|
||||||
});
|
const response = getDatesTabData(courseId);
|
||||||
const camelCaseResponse = {
|
expect(response).toBeTruthy();
|
||||||
datesBannerInfo: {
|
expect(response).toEqual(camelCaseResponse);
|
||||||
missedDeadlines: false,
|
}, 100);
|
||||||
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,6 +21,18 @@ describe('Data layer integration tests', () => {
|
|||||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
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;
|
let store;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -55,16 +67,40 @@ describe('Data layer integration tests', () => {
|
|||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||||
expect(state).toMatchSnapshot();
|
expect(state).toMatchSnapshot({
|
||||||
|
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||||
|
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||||
|
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||||
|
learningAssistant: expect.objectContaining({
|
||||||
|
conversationId: expect.any(String),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([401, 403, 404])(
|
||||||
|
'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', () => {
|
describe('Test fetchOutlineTab', () => {
|
||||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
|
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
|
||||||
|
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||||
|
|
||||||
it('Should result in fetch failure if error occurs', async () => {
|
it('Should result in fetch failure if error occurs', async () => {
|
||||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
axiosMock.onGet(outlineUrl).networkError();
|
||||||
|
|
||||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||||
|
|
||||||
@@ -75,8 +111,6 @@ describe('Data layer integration tests', () => {
|
|||||||
it('Should fetch, normalize, and save metadata', async () => {
|
it('Should fetch, normalize, and save metadata', async () => {
|
||||||
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
||||||
|
|
||||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
|
||||||
|
|
||||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||||
|
|
||||||
@@ -84,8 +118,31 @@ describe('Data layer integration tests', () => {
|
|||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||||
expect(state).toMatchSnapshot();
|
expect(state).toMatchSnapshot({
|
||||||
|
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||||
|
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||||
|
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||||
|
learningAssistant: expect.objectContaining({
|
||||||
|
conversationId: expect.any(String),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([401, 403, 404])(
|
||||||
|
'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', () => {
|
describe('Test fetchProgressTab', () => {
|
||||||
@@ -113,7 +170,14 @@ describe('Data layer integration tests', () => {
|
|||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||||
expect(state).toMatchSnapshot();
|
expect(state).toMatchSnapshot({
|
||||||
|
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||||
|
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||||
|
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||||
|
learningAssistant: expect.objectContaining({
|
||||||
|
conversationId: expect.any(String),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should handle the url including a targetUserId', async () => {
|
it('Should handle the url including a targetUserId', async () => {
|
||||||
@@ -129,6 +193,19 @@ describe('Data layer integration tests', () => {
|
|||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
expect(state.courseHome.targetUserId).toEqual(2);
|
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', () => {
|
describe('Test saveCourseGoal', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route } from 'react-router';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import { getConfig, history } from '@edx/frontend-platform';
|
import { getConfig, history } from '@edx/frontend-platform';
|
||||||
@@ -32,11 +32,16 @@ describe('DatesTab', () => {
|
|||||||
component = (
|
component = (
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<UserMessagesProvider>
|
<UserMessagesProvider>
|
||||||
<Route path="/course/:courseId/dates">
|
<Routes>
|
||||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
<Route
|
||||||
<DatesTab />
|
path="/course/:courseId/dates"
|
||||||
</TabContainer>
|
element={(
|
||||||
</Route>
|
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||||
|
<DatesTab />
|
||||||
|
</TabContainer>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</UserMessagesProvider>
|
</UserMessagesProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,21 +2,20 @@ import { getConfig } from '@edx/frontend-platform';
|
|||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { generatePath, useHistory } from 'react-router';
|
import { useParams, generatePath, useNavigate } from 'react-router-dom';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
|
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
|
||||||
|
|
||||||
const DiscussionTab = () => {
|
const DiscussionTab = () => {
|
||||||
const { courseId } = useSelector(state => state.courseHome);
|
const { courseId } = useSelector(state => state.courseHome);
|
||||||
const { path } = useParams();
|
const { path } = useParams();
|
||||||
const [originalPath] = useState(path);
|
const [originalPath] = useState(path);
|
||||||
const history = useHistory();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [, iFrameHeight] = useIFrameHeight();
|
const [, iFrameHeight] = useIFrameHeight();
|
||||||
useIFramePluginEvents({
|
useIFramePluginEvents({
|
||||||
'discussions.navigate': (payload) => {
|
'discussions.navigate': (payload) => {
|
||||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||||
history.push(`${basePath}/${payload.path}`);
|
navigate(`${basePath}/${payload.path}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
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 { render } from '@testing-library/react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route } from 'react-router';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||||
import {
|
import {
|
||||||
@@ -30,11 +30,16 @@ describe('DiscussionTab', () => {
|
|||||||
component = (
|
component = (
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<UserMessagesProvider>
|
<UserMessagesProvider>
|
||||||
<Route path="/course/:courseId/discussion">
|
<Routes>
|
||||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
<Route
|
||||||
<DiscussionTab />
|
path="/course/:courseId/discussion"
|
||||||
</TabContainer>
|
element={(
|
||||||
</Route>
|
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||||
|
<DiscussionTab />
|
||||||
|
</TabContainer>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</UserMessagesProvider>
|
</UserMessagesProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route } from 'react-router';
|
import {
|
||||||
|
MemoryRouter, Route, Routes,
|
||||||
|
} from 'react-router-dom';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { getConfig, history } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
@@ -24,13 +26,16 @@ describe('GoalUnsubscribe', () => {
|
|||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
store = initializeStore();
|
store = initializeStore();
|
||||||
component = (
|
component = (
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
<UserMessagesProvider>
|
<UserMessagesProvider>
|
||||||
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
|
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
</UserMessagesProvider>
|
</UserMessagesProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts with a spinner', () => {
|
it('starts with a spinner', () => {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
import { AlertList } from '../../generic/user-messages';
|
import { AlertList } from '../../generic/user-messages';
|
||||||
@@ -67,6 +66,7 @@ const OutlineTab = ({ intl }) => {
|
|||||||
} = useModel('coursewareMeta', courseId);
|
} = useModel('coursewareMeta', courseId);
|
||||||
|
|
||||||
const [expandAll, setExpandAll] = useState(false);
|
const [expandAll, setExpandAll] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const eventProperties = {
|
const eventProperties = {
|
||||||
org_key: org,
|
org_key: org,
|
||||||
@@ -115,8 +115,10 @@ const OutlineTab = ({ intl }) => {
|
|||||||
// Deleting the course_start query param as it only needs to be set once
|
// Deleting the course_start query param as it only needs to be set once
|
||||||
// whenever passed in query params.
|
// whenever passed in query params.
|
||||||
currentParams.delete('start_course');
|
currentParams.delete('start_course');
|
||||||
history.replace({
|
navigate({
|
||||||
search: currentParams.toString(),
|
pathname: location.pathname,
|
||||||
|
search: `?${currentParams.toString()}`,
|
||||||
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [location.search]);
|
}, [location.search]);
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
|
|||||||
|
|
||||||
// Click to expand section
|
// Click to expand section
|
||||||
userEvent.click(expandButton);
|
userEvent.click(expandButton);
|
||||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||||
|
|
||||||
// Click to collapse section
|
// Click to collapse section
|
||||||
userEvent.click(expandButton);
|
userEvent.click(expandButton);
|
||||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays correct icon for complete assignment', async () => {
|
it('displays correct icon for complete assignment', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
import { defaultMemoize as memoize } from 'reselect';
|
import { defaultMemoize as memoize } from 'reselect';
|
||||||
|
|
||||||
@@ -17,45 +16,46 @@ import { TabPage } from '../tab-page';
|
|||||||
|
|
||||||
import Course from './course';
|
import Course from './course';
|
||||||
import { handleNextSectionCelebration } from './course/celebration';
|
import { handleNextSectionCelebration } from './course/celebration';
|
||||||
|
import withParamsAndNavigation from './utils';
|
||||||
|
|
||||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
|
||||||
if (courseStatus === 'loaded' && !sequenceId) {
|
if (courseStatus === 'loaded' && !sequenceId) {
|
||||||
// Note that getResumeBlock is just an API call, not a redux thunk.
|
// Note that getResumeBlock is just an API call, not a redux thunk.
|
||||||
getResumeBlock(courseId).then((data) => {
|
getResumeBlock(courseId).then((data) => {
|
||||||
// This is a replace because we don't want this change saved in the browser's history.
|
// This is a replace because we don't want this change saved in the browser's history.
|
||||||
if (data.sectionId && data.unitId) {
|
if (data.sectionId && data.unitId) {
|
||||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
|
||||||
} else if (firstSequenceId) {
|
} else if (firstSequenceId) {
|
||||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
||||||
history.replace(`/course/${courseId}/${unitId}`);
|
navigate(`/course/${courseId}/${unitId}`, { replace: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
|
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
|
||||||
// If the section is non-empty, redirect to its first sequence.
|
// If the section is non-empty, redirect to its first sequence.
|
||||||
if (section.sequenceIds && section.sequenceIds[0]) {
|
if (section.sequenceIds && section.sequenceIds[0]) {
|
||||||
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
|
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
|
||||||
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
||||||
} else {
|
} else {
|
||||||
history.replace(`/course/${courseId}`);
|
navigate(`/course/${courseId}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||||
const checkUnitToSequenceUnitRedirect = memoize(
|
const checkUnitToSequenceUnitRedirect = memoize(
|
||||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
|
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
|
||||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
|
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
|
||||||
if (sequenceMightBeUnit) {
|
if (sequenceMightBeUnit) {
|
||||||
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
|
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
|
||||||
@@ -64,60 +64,62 @@ const checkUnitToSequenceUnitRedirect = memoize(
|
|||||||
getSequenceForUnitDeprecated(courseId, unitId).then(
|
getSequenceForUnitDeprecated(courseId, unitId).then(
|
||||||
parentId => {
|
parentId => {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
|
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
history.replace(`/course/${courseId}`);
|
navigate(`/course/${courseId}`, { replace: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => { // error case
|
() => { // error case
|
||||||
history.replace(`/course/${courseId}`);
|
navigate(`/course/${courseId}`, { replace: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Invalid sequence that isn't a unit either. Redirect up to main course.
|
// Invalid sequence that isn't a unit either. Redirect up to main course.
|
||||||
history.replace(`/course/${courseId}`);
|
navigate(`/course/${courseId}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||||
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
|
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
|
||||||
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
||||||
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
||||||
// This is a replace because we don't want this change saved in the browser's history.
|
// This is a replace because we don't want this change saved in the browser's history.
|
||||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
|
||||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
(courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||||
return;
|
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
const hasUnits = sequence.unitIds?.length > 0;
|
|
||||||
|
|
||||||
if (unitId === 'first') {
|
|
||||||
if (hasUnits) {
|
|
||||||
const firstUnitId = sequence.unitIds[0];
|
|
||||||
history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
|
|
||||||
} else {
|
|
||||||
// No units... go to general sequence page
|
|
||||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
|
||||||
}
|
}
|
||||||
} else if (unitId === 'last') {
|
|
||||||
if (hasUnits) {
|
const hasUnits = sequence.unitIds?.length > 0;
|
||||||
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
|
|
||||||
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
|
if (unitId === 'first') {
|
||||||
} else {
|
if (hasUnits) {
|
||||||
|
const firstUnitId = sequence.unitIds[0];
|
||||||
|
navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true });
|
||||||
|
} else {
|
||||||
// No units... go to general sequence page
|
// No units... go to general sequence page
|
||||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
|
||||||
|
}
|
||||||
|
} else if (unitId === 'last') {
|
||||||
|
if (hasUnits) {
|
||||||
|
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
|
||||||
|
navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true });
|
||||||
|
} else {
|
||||||
|
// No units... go to general sequence page
|
||||||
|
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
class CoursewareContainer extends Component {
|
class CoursewareContainer extends Component {
|
||||||
checkSaveSequencePosition = memoize((unitId) => {
|
checkSaveSequencePosition = memoize((unitId) => {
|
||||||
@@ -145,12 +147,8 @@ class CoursewareContainer extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const {
|
const {
|
||||||
match: {
|
routeCourseId,
|
||||||
params: {
|
routeSequenceId,
|
||||||
courseId: routeCourseId,
|
|
||||||
sequenceId: routeSequenceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
// Load data whenever the course or sequence ID changes.
|
// Load data whenever the course or sequence ID changes.
|
||||||
this.checkFetchCourse(routeCourseId);
|
this.checkFetchCourse(routeCourseId);
|
||||||
@@ -167,13 +165,10 @@ class CoursewareContainer extends Component {
|
|||||||
sequence,
|
sequence,
|
||||||
firstSequenceId,
|
firstSequenceId,
|
||||||
sectionViaSequenceId,
|
sectionViaSequenceId,
|
||||||
match: {
|
routeCourseId,
|
||||||
params: {
|
routeSequenceId,
|
||||||
courseId: routeCourseId,
|
routeUnitId,
|
||||||
sequenceId: routeSequenceId,
|
navigate,
|
||||||
unitId: routeUnitId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
// Load data whenever the course or sequence ID changes.
|
// Load data whenever the course or sequence ID changes.
|
||||||
@@ -202,7 +197,7 @@ class CoursewareContainer extends Component {
|
|||||||
// Check resume redirect:
|
// Check resume redirect:
|
||||||
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
|
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
|
||||||
// based on sequence/unit where user was last active.
|
// based on sequence/unit where user was last active.
|
||||||
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
|
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
|
||||||
|
|
||||||
// Check section-unit to unit redirect:
|
// Check section-unit to unit redirect:
|
||||||
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
|
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
|
||||||
@@ -215,60 +210,54 @@ class CoursewareContainer extends Component {
|
|||||||
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
|
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
|
||||||
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
|
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
|
||||||
// and `checkUnitToSequenceUnitRedirect`.
|
// and `checkUnitToSequenceUnitRedirect`.
|
||||||
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
|
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
|
||||||
|
|
||||||
// Check section to sequence redirect:
|
// Check section to sequence redirect:
|
||||||
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
|
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
|
||||||
// by redirecting to the first sequence within the section.
|
// by redirecting to the first sequence within the section.
|
||||||
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
|
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
|
||||||
|
|
||||||
// Check unit to sequence-unit redirect:
|
// Check unit to sequence-unit redirect:
|
||||||
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
|
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
|
||||||
// by filling in the ID of the parent sequence of :unitId.
|
// by filling in the ID of the parent sequence of :unitId.
|
||||||
checkUnitToSequenceUnitRedirect((
|
checkUnitToSequenceUnitRedirect((
|
||||||
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
|
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
|
||||||
|
sequenceId, sectionViaSequenceId, routeUnitId, navigate
|
||||||
));
|
));
|
||||||
|
|
||||||
// Check sequence to sequence-unit redirect:
|
// Check sequence to sequence-unit redirect:
|
||||||
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
|
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
|
||||||
// by filling in the ID the most-recently-active unit in the sequence, OR
|
// 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.
|
// the ID of the first unit the sequence if none is active.
|
||||||
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
|
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
|
||||||
|
|
||||||
// Check sequence-unit marker to sequence-unit redirect:
|
// Check sequence-unit marker to sequence-unit redirect:
|
||||||
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
|
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
|
||||||
// /course/:courseId/:sequenceId/last -> /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.
|
// 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.
|
// "Sequence unit marker" is an invented term used only in this component.
|
||||||
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
|
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUnitNavigationClick = (nextUnitId) => {
|
handleUnitNavigationClick = () => {
|
||||||
const {
|
const {
|
||||||
courseId, sequenceId,
|
courseId,
|
||||||
match: {
|
sequenceId,
|
||||||
params: {
|
routeUnitId,
|
||||||
unitId: routeUnitId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNextSequenceClick = () => {
|
handleNextSequenceClick = () => {
|
||||||
const {
|
const {
|
||||||
course,
|
course,
|
||||||
courseId,
|
|
||||||
nextSequence,
|
nextSequence,
|
||||||
sequence,
|
sequence,
|
||||||
sequenceId,
|
sequenceId,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (nextSequence !== null) {
|
if (nextSequence !== null) {
|
||||||
history.push(`/course/${courseId}/${nextSequence.id}/first`);
|
|
||||||
|
|
||||||
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
|
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
|
||||||
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
|
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
|
||||||
handleNextSectionCelebration(sequenceId, nextSequence.id);
|
handleNextSectionCelebration(sequenceId, nextSequence.id);
|
||||||
@@ -276,23 +265,14 @@ class CoursewareContainer extends Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePreviousSequenceClick = () => {
|
handlePreviousSequenceClick = () => {};
|
||||||
const { previousSequence, courseId } = this.props;
|
|
||||||
if (previousSequence !== null) {
|
|
||||||
history.push(`/course/${courseId}/${previousSequence.id}/last`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
courseStatus,
|
courseStatus,
|
||||||
courseId,
|
courseId,
|
||||||
sequenceId,
|
sequenceId,
|
||||||
match: {
|
routeUnitId,
|
||||||
params: {
|
|
||||||
unitId: routeUnitId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -335,13 +315,9 @@ const courseShape = PropTypes.shape({
|
|||||||
});
|
});
|
||||||
|
|
||||||
CoursewareContainer.propTypes = {
|
CoursewareContainer.propTypes = {
|
||||||
match: PropTypes.shape({
|
routeCourseId: PropTypes.string.isRequired,
|
||||||
params: PropTypes.shape({
|
routeSequenceId: PropTypes.string,
|
||||||
courseId: PropTypes.string.isRequired,
|
routeUnitId: PropTypes.string,
|
||||||
sequenceId: PropTypes.string,
|
|
||||||
unitId: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
}).isRequired,
|
|
||||||
courseId: PropTypes.string,
|
courseId: PropTypes.string,
|
||||||
sequenceId: PropTypes.string,
|
sequenceId: PropTypes.string,
|
||||||
firstSequenceId: PropTypes.string,
|
firstSequenceId: PropTypes.string,
|
||||||
@@ -357,11 +333,14 @@ CoursewareContainer.propTypes = {
|
|||||||
checkBlockCompletion: PropTypes.func.isRequired,
|
checkBlockCompletion: PropTypes.func.isRequired,
|
||||||
fetchCourse: PropTypes.func.isRequired,
|
fetchCourse: PropTypes.func.isRequired,
|
||||||
fetchSequence: PropTypes.func.isRequired,
|
fetchSequence: PropTypes.func.isRequired,
|
||||||
|
navigate: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
CoursewareContainer.defaultProps = {
|
CoursewareContainer.defaultProps = {
|
||||||
courseId: null,
|
courseId: null,
|
||||||
sequenceId: null,
|
sequenceId: null,
|
||||||
|
routeSequenceId: null,
|
||||||
|
routeUnitId: null,
|
||||||
firstSequenceId: null,
|
firstSequenceId: null,
|
||||||
nextSequence: null,
|
nextSequence: null,
|
||||||
previousSequence: null,
|
previousSequence: null,
|
||||||
@@ -476,4 +455,4 @@ export default connect(mapStateToProps, {
|
|||||||
saveSequencePosition,
|
saveSequencePosition,
|
||||||
fetchCourse,
|
fetchCourse,
|
||||||
fetchSequence,
|
fetchSequence,
|
||||||
})(CoursewareContainer);
|
})(withParamsAndNavigation(CoursewareContainer));
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
|
|||||||
import '@testing-library/jest-dom/extend-expect';
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route, Switch } from 'react-router';
|
import {
|
||||||
|
BrowserRouter, MemoryRouter, Route, Routes,
|
||||||
|
} from 'react-router-dom';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
import { UserMessagesProvider } from '../generic/user-messages';
|
import { UserMessagesProvider } from '../generic/user-messages';
|
||||||
import tabMessages from '../tab-page/messages';
|
import tabMessages from '../tab-page/messages';
|
||||||
import { initializeMockApp } from '../setupTest';
|
import { initializeMockApp, waitFor } from '../setupTest';
|
||||||
|
import { DECODE_ROUTES } from '../constants';
|
||||||
|
|
||||||
import CoursewareContainer from './CoursewareContainer';
|
import CoursewareContainer from './CoursewareContainer';
|
||||||
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
||||||
@@ -80,18 +83,16 @@ describe('CoursewareContainer', () => {
|
|||||||
store = initializeStore();
|
store = initializeStore();
|
||||||
|
|
||||||
component = (
|
component = (
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
<UserMessagesProvider>
|
<UserMessagesProvider>
|
||||||
<Switch>
|
<Routes>
|
||||||
<Route
|
{DECODE_ROUTES.COURSEWARE.map((route) => (
|
||||||
path={[
|
<Route
|
||||||
'/course/:courseId/:sequenceId/:unitId',
|
path={route}
|
||||||
'/course/:courseId/:sequenceId',
|
element={<CoursewareContainer />}
|
||||||
'/course/:courseId',
|
/>
|
||||||
]}
|
))}
|
||||||
component={CoursewareContainer}
|
</Routes>
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</UserMessagesProvider>
|
</UserMessagesProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
@@ -151,7 +152,7 @@ describe('CoursewareContainer', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadContainer() {
|
async function loadContainer() {
|
||||||
const { container } = render(component);
|
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
|
||||||
// Wait for the page spinner to be removed, such that we can wait for our main
|
// Wait for the page spinner to be removed, such that we can wait for our main
|
||||||
// content to load before making any assertions.
|
// content to load before making any assertions.
|
||||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||||
@@ -160,7 +161,7 @@ describe('CoursewareContainer', () => {
|
|||||||
|
|
||||||
it('should initialize to show a spinner', () => {
|
it('should initialize to show a spinner', () => {
|
||||||
history.push('/course/abc123');
|
history.push('/course/abc123');
|
||||||
render(component);
|
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
|
||||||
|
|
||||||
const spinner = screen.getByRole('status');
|
const spinner = screen.getByRole('status');
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ describe('CoursewareContainer', () => {
|
|||||||
|
|
||||||
function assertSequenceNavigation(container, expectedUnitCount = 3) {
|
function assertSequenceNavigation(container, expectedUnitCount = 3) {
|
||||||
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
|
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
|
||||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
|
||||||
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
|
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
|
||||||
|
|
||||||
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
|
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
|
||||||
@@ -211,7 +212,7 @@ describe('CoursewareContainer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
history.push(`/course/${courseId}`);
|
history.push(`/course/${courseId}`);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -234,7 +235,7 @@ describe('CoursewareContainer', () => {
|
|||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
||||||
|
|
||||||
history.push(`/course/${courseId}`);
|
history.push(`/course/${courseId}`);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -284,7 +285,7 @@ describe('CoursewareContainer', () => {
|
|||||||
describe('when the URL does not contain a unit ID', () => {
|
describe('when the URL does not contain a unit ID', () => {
|
||||||
it('should choose a unit within the section\'s first sequence', async () => {
|
it('should choose a unit within the section\'s first sequence', async () => {
|
||||||
setUrl(sectionTree[1].id);
|
setUrl(sectionTree[1].id);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container, 2);
|
assertSequenceNavigation(container, 2);
|
||||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
||||||
@@ -359,7 +360,7 @@ describe('CoursewareContainer', () => {
|
|||||||
|
|
||||||
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -378,7 +379,7 @@ describe('CoursewareContainer', () => {
|
|||||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||||
|
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -395,7 +396,7 @@ describe('CoursewareContainer', () => {
|
|||||||
|
|
||||||
it('should load the specified unit', async () => {
|
it('should load the specified unit', async () => {
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -411,12 +412,12 @@ describe('CoursewareContainer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||||
const container = await loadContainer();
|
const container = await waitFor(() => loadContainer());
|
||||||
|
|
||||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
|
||||||
const sequenceNextButton = sequenceNavButtons[4];
|
const sequenceNextButton = sequenceNavButtons[4];
|
||||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||||
fireEvent.click(sequenceNavButtons[4]);
|
fireEvent.click(sequenceNextButton);
|
||||||
|
|
||||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,56 +1,44 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch, useRouteMatch } from 'react-router';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { PageRoute } from '@edx/frontend-platform/react';
|
import { PageWrap } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import queryString from 'query-string';
|
|
||||||
import PageLoading from '../generic/PageLoading';
|
import PageLoading from '../generic/PageLoading';
|
||||||
|
|
||||||
import DecodePageRoute from '../decode-page-route';
|
import DecodePageRoute from '../decode-page-route';
|
||||||
|
import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants';
|
||||||
|
import RedirectPage from './RedirectPage';
|
||||||
|
|
||||||
const CoursewareRedirectLandingPage = () => {
|
const CoursewareRedirectLandingPage = () => (
|
||||||
const { path } = useRouteMatch();
|
<div className="flex-grow-1">
|
||||||
return (
|
<PageLoading srMessage={(
|
||||||
<div className="flex-grow-1">
|
<FormattedMessage
|
||||||
<PageLoading srMessage={(
|
id="learn.redirect.interstitial.message"
|
||||||
<FormattedMessage
|
description="The screen-reader message when a page is about to redirect"
|
||||||
id="learn.redirect.interstitial.message"
|
defaultMessage="Redirecting..."
|
||||||
description="The screen-reader message when a page is about to redirect"
|
|
||||||
defaultMessage="Redirecting..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Switch>
|
<Routes>
|
||||||
<DecodePageRoute
|
<Route
|
||||||
path={`${path}/survey/:courseId`}
|
path={DECODE_ROUTES.REDIRECT_SURVEY}
|
||||||
render={({ match }) => {
|
element={<DecodePageRoute><RedirectPage pattern="/courses/:courseId/survey" mode={REDIRECT_MODES.SURVEY_REDIRECT} /></DecodePageRoute>}
|
||||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
|
/>
|
||||||
}}
|
<Route
|
||||||
/>
|
path={ROUTES.DASHBOARD}
|
||||||
<PageRoute
|
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>}
|
||||||
path={`${path}/dashboard`}
|
/>
|
||||||
render={({ location }) => {
|
<Route
|
||||||
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
|
path={ROUTES.CONSENT}
|
||||||
}}
|
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
<PageRoute
|
<Route
|
||||||
path={`${path}/consent/`}
|
path={DECODE_ROUTES.REDIRECT_HOME}
|
||||||
render={({ location }) => {
|
element={<DecodePageRoute><RedirectPage pattern="/course/:courseId/home" mode={REDIRECT_MODES.HOME_REDIRECT} /></DecodePageRoute>}
|
||||||
const { consentPath } = queryString.parse(location.search);
|
/>
|
||||||
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
|
</Routes>
|
||||||
}}
|
</div>
|
||||||
/>
|
);
|
||||||
<DecodePageRoute
|
|
||||||
path={`${path}/home/:courseId`}
|
|
||||||
render={({ match }) => {
|
|
||||||
global.location.assign(`/course/${match.params.courseId}/home`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CoursewareRedirectLandingPage;
|
export default CoursewareRedirectLandingPage;
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Router } from 'react-router';
|
import { MemoryRouter as Router } from 'react-router-dom';
|
||||||
import { createMemoryHistory } from 'history';
|
|
||||||
import { render, initializeMockApp } from '../setupTest';
|
import { render, initializeMockApp } from '../setupTest';
|
||||||
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
|
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
|
||||||
|
|
||||||
const redirectUrl = jest.fn();
|
const redirectUrl = jest.fn();
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
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', () => {
|
describe('CoursewareRedirectLandingPage', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -23,12 +16,8 @@ describe('CoursewareRedirectLandingPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Redirects to correct consent URL', () => {
|
it('Redirects to correct consent URL', () => {
|
||||||
const history = createMemoryHistory({
|
|
||||||
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Router history={history}>
|
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
|
||||||
<CoursewareRedirectLandingPage />
|
<CoursewareRedirectLandingPage />
|
||||||
</Router>,
|
</Router>,
|
||||||
);
|
);
|
||||||
@@ -37,12 +26,8 @@ describe('CoursewareRedirectLandingPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Redirects to correct consent URL', () => {
|
it('Redirects to correct consent URL', () => {
|
||||||
const history = createMemoryHistory({
|
|
||||||
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Router history={history}>
|
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
|
||||||
<CoursewareRedirectLandingPage />
|
<CoursewareRedirectLandingPage />
|
||||||
</Router>,
|
</Router>,
|
||||||
);
|
);
|
||||||
|
|||||||
45
src/courseware/RedirectPage.jsx
Normal file
45
src/courseware/RedirectPage.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {
|
||||||
|
generatePath, useParams, useLocation,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
|
import queryString from 'query-string';
|
||||||
|
import { REDIRECT_MODES } from '../constants';
|
||||||
|
|
||||||
|
const RedirectPage = ({
|
||||||
|
pattern, mode,
|
||||||
|
}) => {
|
||||||
|
const { courseId } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const { consentPath } = queryString.parse(location?.search);
|
||||||
|
|
||||||
|
const BASE_URL = getConfig().LMS_BASE_URL;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case REDIRECT_MODES.DASHBOARD_REDIRECT:
|
||||||
|
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
|
||||||
|
break;
|
||||||
|
case REDIRECT_MODES.CONSENT_REDIRECT:
|
||||||
|
global.location.assign(`${BASE_URL}${consentPath}`);
|
||||||
|
break;
|
||||||
|
case REDIRECT_MODES.HOME_REDIRECT:
|
||||||
|
global.location.assign(generatePath(pattern, { courseId }));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
RedirectPage.propTypes = {
|
||||||
|
pattern: PropTypes.string,
|
||||||
|
mode: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
RedirectPage.defaultProps = {
|
||||||
|
pattern: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RedirectPage;
|
||||||
69
src/courseware/RedirectPage.test.jsx
Normal file
69
src/courseware/RedirectPage.test.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
|
import RedirectPage from './RedirectPage';
|
||||||
|
import { REDIRECT_MODES } from '../constants';
|
||||||
|
|
||||||
|
const BASE_URL = getConfig().LMS_BASE_URL;
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useParams: () => ({
|
||||||
|
courseId: 'course-id-123',
|
||||||
|
}),
|
||||||
|
useLocation: () => ({
|
||||||
|
search: '?consentPath=/some-path',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RedirectPage component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { assign: jest.fn() },
|
||||||
|
});
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DASHBOARD_REDIRECT correctly', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<RedirectPage mode={REDIRECT_MODES.DASHBOARD_REDIRECT} pattern="/dashboard" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle CONSENT_REDIRECT correctly', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HOME_REDIRECT correctly', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<RedirectPage mode={REDIRECT_MODES.HOME_REDIRECT} pattern="/course/:courseId/home" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the default case correctly', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<RedirectPage pattern="/default/:courseId" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { AlertList } from '../../generic/user-messages';
|
|||||||
import Sequence from './sequence';
|
import Sequence from './sequence';
|
||||||
|
|
||||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||||
|
import Chat from './chat/Chat';
|
||||||
import ContentTools from './content-tools';
|
import ContentTools from './content-tools';
|
||||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||||
@@ -91,7 +92,16 @@ const Course = ({
|
|||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
/>
|
/>
|
||||||
{shouldDisplayTriggers && (
|
{shouldDisplayTriggers && (
|
||||||
<SidebarTriggers />
|
<>
|
||||||
|
<Chat
|
||||||
|
enabled={course.learningAssistantEnabled}
|
||||||
|
enrollmentMode={course.enrollmentMode}
|
||||||
|
isStaff={isStaff}
|
||||||
|
courseId={courseId}
|
||||||
|
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||||
|
/>
|
||||||
|
<SidebarTriggers />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { executeThunk } from '../../utils';
|
|||||||
import * as thunks from '../data/thunks';
|
import * as thunks from '../data/thunks';
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
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();
|
const recordFirstSectionCelebration = jest.fn();
|
||||||
// eslint-disable-next-line no-import-assign
|
// eslint-disable-next-line no-import-assign
|
||||||
@@ -49,8 +53,7 @@ describe('Course', () => {
|
|||||||
setItemSpy.mockRestore();
|
setItemSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupDiscussionSidebar = async (storageValue = false) => {
|
const setupDiscussionSidebar = async () => {
|
||||||
localStorage.clear();
|
|
||||||
const testStore = await initializeTestStore({ provider: 'openedx' });
|
const testStore = await initializeTestStore({ provider: 'openedx' });
|
||||||
const state = testStore.getState();
|
const state = testStore.getState();
|
||||||
const { courseware: { courseId } } = state;
|
const { courseware: { courseId } } = state;
|
||||||
@@ -65,14 +68,12 @@ describe('Course', () => {
|
|||||||
mockData.unitId = firstUnitId;
|
mockData.unitId = firstUnitId;
|
||||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
const [firstSequenceId] = Object.keys(state.models.sequences);
|
||||||
mockData.sequenceId = firstSequenceId;
|
mockData.sequenceId = firstSequenceId;
|
||||||
if (storageValue !== null) {
|
|
||||||
localStorage.setItem('showDiscussionSidebar', storageValue);
|
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||||
}
|
|
||||||
await render(<Course {...mockData} />, { store: testStore });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it('loads learning sequence', async () => {
|
it('loads learning sequence', async () => {
|
||||||
render(<Course {...mockData} />);
|
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ describe('Course', () => {
|
|||||||
};
|
};
|
||||||
// Set up LocalStorage for testing.
|
// Set up LocalStorage for testing.
|
||||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||||
@@ -123,7 +124,7 @@ describe('Course', () => {
|
|||||||
sequenceId,
|
sequenceId,
|
||||||
unitId: Object.values(models.units)[0].id,
|
unitId: Object.values(models.units)[0].id,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||||
@@ -131,31 +132,71 @@ describe('Course', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays notification trigger and toggles active class on click', async () => {
|
it('displays notification trigger and toggles active class on click', async () => {
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||||
render(<Course {...mockData} />);
|
|
||||||
|
|
||||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||||
expect(notificationTrigger).toBeInTheDocument();
|
expect(notificationTrigger).toBeInTheDocument();
|
||||||
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
|
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||||
fireEvent.click(notificationTrigger);
|
fireEvent.click(notificationTrigger);
|
||||||
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles click to open/close notification tray', async () => {
|
it('handles click to open/close notification tray', async () => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||||
render(<Course {...mockData} />);
|
|
||||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||||
fireEvent.click(notificationShowButton);
|
fireEvent.click(notificationShowButton);
|
||||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles reload persisting notification tray status', async () => {
|
it('handles reload persisting notification tray status', async () => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
render(<Course {...mockData} />);
|
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||||
fireEvent.click(notificationShowButton);
|
fireEvent.click(notificationShowButton);
|
||||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||||
@@ -174,13 +215,12 @@ describe('Course', () => {
|
|||||||
|
|
||||||
it('handles sessionStorage from a different course for the notification tray', async () => {
|
it('handles sessionStorage from a different course for the notification tray', async () => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
|
||||||
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
|
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
|
||||||
|
|
||||||
// set sessionStorage for a different course before rendering Course
|
// set sessionStorage for a different course before rendering Course
|
||||||
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
|
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
|
||||||
|
|
||||||
render(<Course {...mockData} />);
|
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||||
fireEvent.click(notificationShowButton);
|
fireEvent.click(notificationShowButton);
|
||||||
@@ -208,7 +248,7 @@ describe('Course', () => {
|
|||||||
sequenceId,
|
sequenceId,
|
||||||
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
|
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
@@ -217,34 +257,6 @@ describe('Course', () => {
|
|||||||
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
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 () => {
|
it('passes handlers to the sequence', async () => {
|
||||||
const nextSequenceHandler = jest.fn();
|
const nextSequenceHandler = jest.fn();
|
||||||
const previousSequenceHandler = jest.fn();
|
const previousSequenceHandler = jest.fn();
|
||||||
@@ -268,12 +280,12 @@ describe('Course', () => {
|
|||||||
previousSequenceHandler,
|
previousSequenceHandler,
|
||||||
unitNavigationHandler,
|
unitNavigationHandler,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||||
|
|
||||||
// We are in the middle of the sequence, so no
|
// We are in the middle of the sequence, so no
|
||||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||||
@@ -297,7 +309,7 @@ describe('Course', () => {
|
|||||||
courseId: courseMetadata.id,
|
courseId: courseMetadata.id,
|
||||||
sequenceId: sequenceBlocks[0].id,
|
sequenceId: sequenceBlocks[0].id,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,7 +343,7 @@ describe('Course', () => {
|
|||||||
courseId: testCourseMetadata.id,
|
courseId: testCourseMetadata.id,
|
||||||
sequenceId: sequenceBlocks[0].id,
|
sequenceId: sequenceBlocks[0].id,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,7 +377,7 @@ describe('Course', () => {
|
|||||||
courseId: testCourseMetadata.id,
|
courseId: testCourseMetadata.id,
|
||||||
sequenceId: sequenceBlocks[0].id,
|
sequenceId: sequenceBlocks[0].id,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
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,57 +1,80 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { SelectMenu } from '@edx/paragon';
|
import { useToggle, ModalPopup, Menu } from '@edx/paragon';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useModel, useModels } from '../../generic/model-store';
|
import { useModel, useModels } from '../../generic/model-store';
|
||||||
import JumpNavMenuItem from './JumpNavMenuItem';
|
import JumpNavMenuItem from './JumpNavMenuItem';
|
||||||
|
|
||||||
const CourseBreadcrumb = ({
|
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 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);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{withSeparator && (
|
{withSeparator && (
|
||||||
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
|
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<li style={{
|
<li
|
||||||
overflow: 'hidden',
|
style={{
|
||||||
textOverflow: 'ellipsis',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
textOverflow: 'ellipsis',
|
||||||
}}
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
data-testid="breadcrumb-item"
|
||||||
>
|
>
|
||||||
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
|
{showRegularLink ? (
|
||||||
? (
|
<Link
|
||||||
<Link
|
className="text-primary-500"
|
||||||
className="text-primary-500"
|
to={
|
||||||
to={defaultContent.sequences.length
|
defaultContent.sequences.length
|
||||||
? `/course/${courseId}/${defaultContent.sequences[0].id}`
|
? `/course/${courseId}/${defaultContent.sequences[0].id}`
|
||||||
: `/course/${courseId}/${defaultContent.id}`}
|
: `/course/${courseId}/${defaultContent.id}`
|
||||||
>
|
}
|
||||||
{defaultContent.label}
|
>
|
||||||
</Link>
|
{defaultContent.label}
|
||||||
)
|
</Link>
|
||||||
: (
|
) : (
|
||||||
<SelectMenu isLink defaultMessage={defaultContent.label}>
|
<>
|
||||||
{content.map(item => (
|
{
|
||||||
<JumpNavMenuItem
|
// eslint-disable-next-line
|
||||||
isDefault={item.default}
|
<a className="text-primary-500" onClick={open} ref={setTarget}>
|
||||||
sequences={item.sequences}
|
{defaultContent.label}
|
||||||
courseId={courseId}
|
</a>
|
||||||
title={item.label}
|
}
|
||||||
currentSequence={sequenceId}
|
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
|
||||||
currentUnit={unitId}
|
<Menu>
|
||||||
/>
|
{content.map((item) => (
|
||||||
))}
|
<JumpNavMenuItem
|
||||||
</SelectMenu>
|
isDefault={item.default}
|
||||||
)}
|
sequences={item.sequences}
|
||||||
|
courseId={courseId}
|
||||||
|
title={item.label}
|
||||||
|
currentSequence={sequenceId}
|
||||||
|
currentUnit={unitId}
|
||||||
|
onClick={close}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</ModalPopup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -87,14 +110,21 @@ const CourseBreadcrumbs = ({
|
|||||||
isStaff,
|
isStaff,
|
||||||
}) => {
|
}) => {
|
||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('coursewareMeta', courseId);
|
||||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
const courseStatus = useSelector((state) => state.courseware.courseStatus);
|
||||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
const sequenceStatus = useSelector(
|
||||||
|
(state) => state.courseware.sequenceStatus,
|
||||||
|
);
|
||||||
|
|
||||||
const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
|
const allSequencesInSections = Object.fromEntries(
|
||||||
default: section.id === sectionId,
|
useModels('sections', course.sectionIds).map((section) => [
|
||||||
title: section.title,
|
section.id,
|
||||||
sequences: useModels('sequences', section.sequenceIds),
|
{
|
||||||
}]));
|
default: section.id === sectionId,
|
||||||
|
title: section.title,
|
||||||
|
sequences: useModels('sequences', section.sequenceIds),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const links = useMemo(() => {
|
const links = useMemo(() => {
|
||||||
const chapters = [];
|
const chapters = [];
|
||||||
@@ -108,7 +138,7 @@ const CourseBreadcrumbs = ({
|
|||||||
sequences: section.sequences,
|
sequences: section.sequences,
|
||||||
});
|
});
|
||||||
if (section.default) {
|
if (section.default) {
|
||||||
section.sequences.forEach(sequence => {
|
section.sequences.forEach((sequence) => {
|
||||||
sequentials.push({
|
sequentials.push({
|
||||||
id: sequence.id,
|
id: sequence.id,
|
||||||
label: sequence.title,
|
label: sequence.title,
|
||||||
@@ -124,11 +154,12 @@ const CourseBreadcrumbs = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
|
<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">
|
<li className="list-unstyled col-auto m-0 p-0">
|
||||||
<Link
|
<Link
|
||||||
className="flex-shrink-0 text-primary"
|
className="flex-shrink-0 text-primary"
|
||||||
to={`/course/${courseId}/home`}
|
to={`/course/${courseId}/home`}
|
||||||
|
replace
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHome} className="mr-2" />
|
<FontAwesomeIcon icon={faHome} className="mr-2" />
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@@ -138,7 +169,7 @@ const CourseBreadcrumbs = ({
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
{links.map(content => (
|
{links.map((content) => (
|
||||||
<CourseBreadcrumb
|
<CourseBreadcrumb
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ jest.mock('react-redux', () => ({
|
|||||||
Provider: ({ children }) => children,
|
Provider: ({ children }) => children,
|
||||||
useSelector: () => 'loaded',
|
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) => {
|
useModels.mockImplementation((name) => {
|
||||||
if (name === 'sections') {
|
if (name === 'sections') {
|
||||||
@@ -123,6 +129,6 @@ describe('CourseBreadcrumbs', () => {
|
|||||||
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
|
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
|
||||||
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
|
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
|
||||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||||
expect(screen.queryAllByRole('button')).toHaveLength(2);
|
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { history } from '@edx/frontend-platform';
|
import { Dropdown } from '@edx/paragon';
|
||||||
import { MenuItem } from '@edx/paragon';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
sendTrackingLogEvent,
|
sendTrackingLogEvent,
|
||||||
sendTrackEvent,
|
sendTrackEvent,
|
||||||
} from '@edx/frontend-platform/analytics';
|
} from '@edx/frontend-platform/analytics';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const JumpNavMenuItem = ({
|
const JumpNavMenuItem = ({
|
||||||
title,
|
title,
|
||||||
@@ -15,7 +15,10 @@ const JumpNavMenuItem = ({
|
|||||||
currentUnit,
|
currentUnit,
|
||||||
sequences,
|
sequences,
|
||||||
isDefault,
|
isDefault,
|
||||||
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
function logEvent(targetUrl) {
|
function logEvent(targetUrl) {
|
||||||
const eventName = 'edx.ui.lms.jump_nav.selected';
|
const eventName = 'edx.ui.lms.jump_nav.selected';
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -34,19 +37,20 @@ const JumpNavMenuItem = ({
|
|||||||
}
|
}
|
||||||
return `/course/${courseId}/${sequences[0].id}`;
|
return `/course/${courseId}/${sequences[0].id}`;
|
||||||
}
|
}
|
||||||
function handleClick() {
|
function handleClick(e) {
|
||||||
const url = destinationUrl();
|
const url = destinationUrl();
|
||||||
logEvent(url);
|
logEvent(url);
|
||||||
history.push(url);
|
navigate(url);
|
||||||
|
if (onClick) { onClick(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<Dropdown.Item
|
||||||
defaultSelected={isDefault}
|
active={isDefault}
|
||||||
onClick={() => handleClick()}
|
onClick={e => handleClick(e)}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</MenuItem>
|
</Dropdown.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +58,10 @@ const sequenceShape = PropTypes.shape({
|
|||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
JumpNavMenuItem.defaultProps = {
|
||||||
|
onClick: null,
|
||||||
|
};
|
||||||
|
|
||||||
JumpNavMenuItem.propTypes = {
|
JumpNavMenuItem.propTypes = {
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
|
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
|
||||||
@@ -61,6 +69,7 @@ JumpNavMenuItem.propTypes = {
|
|||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
currentSequence: PropTypes.string.isRequired,
|
currentSequence: PropTypes.string.isRequired,
|
||||||
currentUnit: PropTypes.string.isRequired,
|
currentUnit: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JumpNavMenuItem;
|
export default JumpNavMenuItem;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { screen, render } from '@testing-library/react';
|
import { screen, render } from '@testing-library/react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import JumpNavMenuItem from './JumpNavMenuItem';
|
import JumpNavMenuItem from './JumpNavMenuItem';
|
||||||
import { fireEvent } from '../../setupTest';
|
import { fireEvent } from '../../setupTest';
|
||||||
|
|
||||||
@@ -22,12 +23,15 @@ const mockData = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
|
onClick: jest.fn().mockName('onClick'),
|
||||||
};
|
};
|
||||||
describe('JumpNavMenuItem', () => {
|
describe('JumpNavMenuItem', () => {
|
||||||
render(
|
render(
|
||||||
<JumpNavMenuItem
|
<BrowserRouter>
|
||||||
{...mockData}
|
<JumpNavMenuItem
|
||||||
/>,
|
{...mockData}
|
||||||
|
/>
|
||||||
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
it('renders menu Item as expected with button and Text and handles clicks', () => {
|
it('renders menu Item as expected with button and Text and handles clicks', () => {
|
||||||
expect(screen.queryAllByRole('button')).toHaveLength(1);
|
expect(screen.queryAllByRole('button')).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import {
|
import {
|
||||||
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
||||||
} from '../../../setupTest';
|
} from '../../../setupTest';
|
||||||
import { BookmarkButton } from './index';
|
import { BookmarkButton } from './index';
|
||||||
|
import { getBookmarksBaseUrl } from './data/api';
|
||||||
|
|
||||||
describe('Bookmark Button', () => {
|
describe('Bookmark Button', () => {
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
@@ -32,7 +32,8 @@ describe('Bookmark Button', () => {
|
|||||||
mockData.unitId = nonBookmarkedUnitBlock.id;
|
mockData.unitId = nonBookmarkedUnitBlock.id;
|
||||||
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
const bookmarkUrl = getBookmarksBaseUrl();
|
||||||
|
|
||||||
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
||||||
|
|
||||||
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
export const getBookmarksBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||||
|
|
||||||
export async function createBookmark(usageId) {
|
export async function createBookmark(usageId) {
|
||||||
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
|
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBookmark(usageId) {
|
export async function deleteBookmark(usageId) {
|
||||||
const { username } = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
|
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/courseware/course/chat/Chat.jsx
Normal file
76
src/courseware/course/chat/Chat.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { Xpert } from '@edx/frontend-lib-learning-assistant';
|
||||||
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
|
|
||||||
|
const Chat = ({
|
||||||
|
enabled,
|
||||||
|
enrollmentMode,
|
||||||
|
isStaff,
|
||||||
|
courseId,
|
||||||
|
contentToolsEnabled,
|
||||||
|
}) => {
|
||||||
|
const VERIFIED_MODES = [
|
||||||
|
'professional',
|
||||||
|
'verified',
|
||||||
|
'no-id-professional',
|
||||||
|
'credit',
|
||||||
|
'masters',
|
||||||
|
'executive-education',
|
||||||
|
'paid-executive-education',
|
||||||
|
'paid-bootcamp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUDIT_MODES = [
|
||||||
|
'audit',
|
||||||
|
'honor',
|
||||||
|
'unpaid-executive-education',
|
||||||
|
'unpaid-bootcamp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isEnrolled = (
|
||||||
|
enrollmentMode !== null
|
||||||
|
&& enrollmentMode !== undefined
|
||||||
|
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldDisplayChat = (
|
||||||
|
enabled
|
||||||
|
&& (isEnrolled || isStaff) // display only to enrolled or staff
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Remove this Segment alert. This has been added purely to diagnose whether
|
||||||
|
// usage issues are as a result of the Xpert toggle button not appearing.
|
||||||
|
if (shouldDisplayChat) {
|
||||||
|
sendTrackEvent('edx.ui.lms.learning_assistant.render', {
|
||||||
|
course_id: courseId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Use a portal to ensure that component overlay does not compete with learning MFE styles. */}
|
||||||
|
{shouldDisplayChat && (createPortal(
|
||||||
|
<Xpert courseId={courseId} contentToolsEnabled={contentToolsEnabled} />,
|
||||||
|
document.body,
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Chat.propTypes = {
|
||||||
|
isStaff: PropTypes.bool.isRequired,
|
||||||
|
enabled: PropTypes.bool.isRequired,
|
||||||
|
enrollmentMode: PropTypes.string,
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
contentToolsEnabled: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
Chat.defaultProps = {
|
||||||
|
enrollmentMode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(Chat);
|
||||||
157
src/courseware/course/chat/Chat.test.jsx
Normal file
157
src/courseware/course/chat/Chat.test.jsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
|
||||||
|
|
||||||
|
import { initializeMockApp, render, screen } from '../../../setupTest';
|
||||||
|
|
||||||
|
import Chat from './Chat';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/analytics');
|
||||||
|
|
||||||
|
initializeMockApp();
|
||||||
|
|
||||||
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
let testCases = [];
|
||||||
|
let enabledTestCases = [];
|
||||||
|
let disabledTestCases = [];
|
||||||
|
const enabledModes = [
|
||||||
|
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
|
||||||
|
'paid-executive-education', 'paid-bootcamp', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp',
|
||||||
|
];
|
||||||
|
const disabledModes = [null, undefined, 'xyz'];
|
||||||
|
|
||||||
|
describe('Chat', () => {
|
||||||
|
// Generate test cases.
|
||||||
|
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
|
||||||
|
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
|
||||||
|
testCases = enabledTestCases.concat(disabledTestCases);
|
||||||
|
|
||||||
|
testCases.forEach(test => {
|
||||||
|
it(
|
||||||
|
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
|
||||||
|
async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
learningAssistant: learningAssistantReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<Chat
|
||||||
|
enrollmentMode={test.enrollmentMode}
|
||||||
|
isStaff={false}
|
||||||
|
enabled
|
||||||
|
courseId={courseId}
|
||||||
|
contentToolsEnabled={false}
|
||||||
|
/>
|
||||||
|
</BrowserRouter>,
|
||||||
|
{ store },
|
||||||
|
);
|
||||||
|
|
||||||
|
const chat = screen.queryByTestId('toggle-button');
|
||||||
|
if (test.isVisible) {
|
||||||
|
expect(chat).toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
expect(chat).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate test cases.
|
||||||
|
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
|
||||||
|
testCases.forEach(test => {
|
||||||
|
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
learningAssistant: learningAssistantReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<Chat
|
||||||
|
enrollmentMode={test.enrollmentMode}
|
||||||
|
isStaff
|
||||||
|
enabled
|
||||||
|
courseId={courseId}
|
||||||
|
contentToolsEnabled={false}
|
||||||
|
/>
|
||||||
|
</BrowserRouter>,
|
||||||
|
{ store },
|
||||||
|
);
|
||||||
|
|
||||||
|
const chat = screen.queryByTestId('toggle-button');
|
||||||
|
if (test.isVisible) {
|
||||||
|
expect(chat).toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
expect(chat).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate the map function used for generating test cases by currying the map function.
|
||||||
|
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
|
||||||
|
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
|
||||||
|
// defining two separate map functions that differ in only one case, curry the function.
|
||||||
|
const generateMapFunction = (areEnabledModes) => (
|
||||||
|
(mode) => (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate test cases.
|
||||||
|
enabledTestCases = enabledModes.map(generateMapFunction(true));
|
||||||
|
disabledTestCases = disabledModes.map(generateMapFunction(false));
|
||||||
|
testCases = enabledTestCases.concat(disabledTestCases);
|
||||||
|
testCases = testCases.flat();
|
||||||
|
testCases.forEach(test => {
|
||||||
|
it(
|
||||||
|
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
|
||||||
|
and ${test.enrollmentMode} enrollment mode`,
|
||||||
|
async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
learningAssistant: learningAssistantReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<Chat
|
||||||
|
enrollmentMode={test.enrollmentMode}
|
||||||
|
isStaff={test.isStaff}
|
||||||
|
enabled={test.enabled}
|
||||||
|
courseId={courseId}
|
||||||
|
contentToolsEnabled={false}
|
||||||
|
/>
|
||||||
|
</BrowserRouter>,
|
||||||
|
{ store },
|
||||||
|
);
|
||||||
|
|
||||||
|
const chat = screen.queryByTestId('toggle-button');
|
||||||
|
if (test.isVisible) {
|
||||||
|
expect(chat).toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
expect(chat).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/courseware/course/chat/index.js
Normal file
1
src/courseware/course/chat/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './Chat';
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import Calculator from './calculator';
|
import Calculator from './calculator';
|
||||||
import NotesVisibility from './notes-visibility';
|
import NotesVisibility from './notes-visibility';
|
||||||
|
|
||||||
const ContentTools = ({
|
const ContentTools = ({
|
||||||
course,
|
course,
|
||||||
}) => (
|
}) => {
|
||||||
<div className="content-tools">
|
const {
|
||||||
<div className="d-flex justify-content-end align-items-end m-0">
|
sidebarIsOpen,
|
||||||
{course.showCalculator && (
|
} = useSelector(state => state.learningAssistant);
|
||||||
<Calculator />
|
|
||||||
)}
|
return (
|
||||||
{course.notes.enabled && (
|
!sidebarIsOpen && (
|
||||||
<NotesVisibility course={course} />
|
<div className="content-tools">
|
||||||
)}
|
<div className="d-flex justify-content-end align-items-end m-0">
|
||||||
</div>
|
{course.showCalculator && (
|
||||||
</div>
|
<Calculator />
|
||||||
);
|
)}
|
||||||
|
{course.notes.enabled && (
|
||||||
|
<NotesVisibility course={course} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
ContentTools.propTypes = {
|
ContentTools.propTypes = {
|
||||||
course: PropTypes.shape({
|
course: PropTypes.shape({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.content-tools {
|
.content-tools {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class NotesVisibility extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleClick = () => {
|
handleClick = () => {
|
||||||
const data = { visibility: this.state.visible };
|
const data = { visibility: !this.state.visible };
|
||||||
getAuthenticatedHttpClient().put(
|
getAuthenticatedHttpClient().put(
|
||||||
this.visibilityUrl,
|
this.visibilityUrl,
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe('Notes Visibility', () => {
|
|||||||
|
|
||||||
expect(axiosMock.history.put).toHaveLength(1);
|
expect(axiosMock.history.put).toHaveLength(1);
|
||||||
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
|
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();
|
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import CourseCelebration from './CourseCelebration';
|
import CourseCelebration from './CourseCelebration';
|
||||||
import CourseInProgress from './CourseInProgress';
|
import CourseInProgress from './CourseInProgress';
|
||||||
@@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => {
|
|||||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||||
body = (<CourseCelebration />);
|
body = (<CourseCelebration />);
|
||||||
} else {
|
} else {
|
||||||
return (<Redirect to={`/course/${courseId}`} />);
|
return (<Navigate to={`/course/${courseId}`} replace />);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
|
|||||||
|
|
||||||
async function fetchAndRender(component) {
|
async function fetchAndRender(component) {
|
||||||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||||||
render(component, { store });
|
render(component, { store, wrapWithRouter: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '@edx/frontend-platform/analytics';
|
} from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
||||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||||
|
|
||||||
@@ -139,9 +138,6 @@ const Sequence = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||||
const goToCourseExitPage = () => {
|
|
||||||
history.push(`/course/${courseId}/course-end`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultContent = (
|
const defaultContent = (
|
||||||
<div className="sequence-container d-inline-flex flex-row">
|
<div className="sequence-container d-inline-flex flex-row">
|
||||||
@@ -150,7 +146,7 @@ const Sequence = ({
|
|||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
nextSequenceHandler={() => {
|
nextHandler={() => {
|
||||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||||
handleNext();
|
handleNext();
|
||||||
}}
|
}}
|
||||||
@@ -158,11 +154,10 @@ const Sequence = ({
|
|||||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||||
handleNavigate(destinationUnitId);
|
handleNavigate(destinationUnitId);
|
||||||
}}
|
}}
|
||||||
previousSequenceHandler={() => {
|
previousHandler={() => {
|
||||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||||
handlePrevious();
|
handlePrevious();
|
||||||
}}
|
}}
|
||||||
goToCourseExitPage={() => goToCourseExitPage()}
|
|
||||||
/>
|
/>
|
||||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||||
|
|
||||||
@@ -186,7 +181,6 @@ const Sequence = ({
|
|||||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||||
handleNext();
|
handleNext();
|
||||||
}}
|
}}
|
||||||
goToCourseExitPage={() => goToCourseExitPage()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import Sequence from './Sequence';
|
|||||||
import { fetchSequenceFailure } from '../../data/slice';
|
import { fetchSequenceFailure } from '../../data/slice';
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
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', () => {
|
describe('Sequence', () => {
|
||||||
let mockData;
|
let mockData;
|
||||||
@@ -42,10 +46,14 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
it('renders correctly without data', async () => {
|
it('renders correctly without data', async () => {
|
||||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||||
render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
|
render(
|
||||||
|
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
|
||||||
|
{ store: testStore, wrapWithRouter: true },
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly for gated content', async () => {
|
it('renders correctly for gated content', async () => {
|
||||||
@@ -70,12 +78,14 @@ describe('Sequence', () => {
|
|||||||
}, false);
|
}, false);
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||||
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
|
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||||
expect(screen.getAllByRole('button').length).toEqual(5);
|
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||||
|
// `Active` and `Next` buttons.
|
||||||
|
expect(screen.getAllByRole('link').length).toEqual(2);
|
||||||
|
|
||||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||||
const unitContainer = container.querySelector('.unit-container');
|
const unitContainer = container.querySelector('.unit-container');
|
||||||
@@ -101,7 +111,7 @@ describe('Sequence', () => {
|
|||||||
}, false);
|
}, false);
|
||||||
render(
|
render(
|
||||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -112,26 +122,30 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
// No normal content or navigation should be rendered. Just the above alert.
|
// No normal content or navigation should be rendered. Just the above alert.
|
||||||
expect(screen.queryAllByRole('button').length).toEqual(0);
|
expect(screen.queryAllByRole('button').length).toEqual(0);
|
||||||
|
expect(screen.queryAllByRole('link').length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays error message on sequence load failure', async () => {
|
it('displays error message on sequence load failure', async () => {
|
||||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||||
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
|
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
|
||||||
render(<Sequence {...mockData} />, { store: testStore });
|
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles loading unit', async () => {
|
it('handles loading unit', async () => {
|
||||||
render(<Sequence {...mockData} />);
|
render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||||
// Renders navigation buttons plus one button for each unit.
|
// `Previous`, `Bookmark` and `Close Tray` buttons
|
||||||
expect(screen.getAllByRole('button')).toHaveLength(4 + unitBlocks.length);
|
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||||
|
// Renders `Next` button plus one button for each unit.
|
||||||
|
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
|
||||||
|
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
// At this point there will be 2 `Previous` and 2 `Next` buttons.
|
// At this point there will be 2 `Previous` and 2 `Next` buttons.
|
||||||
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
|
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
|
||||||
|
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sequence and unit navigation buttons', () => {
|
describe('sequence and unit navigation buttons', () => {
|
||||||
@@ -160,10 +174,10 @@ describe('Sequence', () => {
|
|||||||
sequenceId: sequenceBlocks[1].id,
|
sequenceId: sequenceBlocks[1].id,
|
||||||
previousSequenceHandler: jest.fn(),
|
previousSequenceHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<Sequence {...testData} />, { store: testStore });
|
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||||
|
|
||||||
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
|
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||||
fireEvent.click(sequencePreviousButton);
|
fireEvent.click(sequencePreviousButton);
|
||||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||||
@@ -176,7 +190,7 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
|
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
|
||||||
.filter(button => button !== sequencePreviousButton)[0];
|
.filter(button => button !== sequencePreviousButton)[0];
|
||||||
fireEvent.click(unitPreviousButton);
|
fireEvent.click(unitPreviousButton);
|
||||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||||
@@ -196,10 +210,10 @@ describe('Sequence', () => {
|
|||||||
sequenceId: sequenceBlocks[0].id,
|
sequenceId: sequenceBlocks[0].id,
|
||||||
nextSequenceHandler: jest.fn(),
|
nextSequenceHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<Sequence {...testData} />, { store: testStore });
|
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||||
|
|
||||||
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
|
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
|
||||||
fireEvent.click(sequenceNextButton);
|
fireEvent.click(sequenceNextButton);
|
||||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
|
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
|
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
|
||||||
@@ -211,7 +225,7 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
|
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
|
||||||
.filter(button => button !== sequenceNextButton)[0];
|
.filter(button => button !== sequenceNextButton)[0];
|
||||||
fireEvent.click(unitNextButton);
|
fireEvent.click(unitNextButton);
|
||||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||||
@@ -234,14 +248,14 @@ describe('Sequence', () => {
|
|||||||
previousSequenceHandler: jest.fn(),
|
previousSequenceHandler: jest.fn(),
|
||||||
nextSequenceHandler: jest.fn(),
|
nextSequenceHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<Sequence {...testData} />, { store: testStore });
|
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
|
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||||
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
|
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
|
||||||
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
|
// 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`.
|
// Therefore the next unit will still be `the initial one + 1`.
|
||||||
@@ -258,7 +272,7 @@ describe('Sequence', () => {
|
|||||||
unitNavigationHandler: jest.fn(),
|
unitNavigationHandler: jest.fn(),
|
||||||
previousSequenceHandler: jest.fn(),
|
previousSequenceHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<Sequence {...testData} />, { store: testStore });
|
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
@@ -277,7 +291,7 @@ describe('Sequence', () => {
|
|||||||
unitNavigationHandler: jest.fn(),
|
unitNavigationHandler: jest.fn(),
|
||||||
nextSequenceHandler: jest.fn(),
|
nextSequenceHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<Sequence {...testData} />, { store: testStore });
|
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
@@ -319,15 +333,15 @@ describe('Sequence', () => {
|
|||||||
nextSequenceHandler: jest.fn(),
|
nextSequenceHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<Sequence {...testData} />, { store: innerTestStore });
|
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||||
loadUnit();
|
loadUnit();
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -367,10 +381,10 @@ describe('Sequence', () => {
|
|||||||
sequenceId: sequenceBlocks[0].id,
|
sequenceId: sequenceBlocks[0].id,
|
||||||
unitNavigationHandler: jest.fn(),
|
unitNavigationHandler: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<Sequence {...testData} />, { store: testStore });
|
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
|
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
|
||||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
|
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
|
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
|
||||||
current_tab: currentTabNumber,
|
current_tab: currentTabNumber,
|
||||||
@@ -394,13 +408,13 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
describe('notification feature', () => {
|
describe('notification feature', () => {
|
||||||
it('renders notification tray in sequence', async () => {
|
it('renders notification tray in sequence', async () => {
|
||||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
|
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||||
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles click on notification tray close button', async () => {
|
it('handles click on notification tray close button', async () => {
|
||||||
const toggleNotificationTray = jest.fn();
|
const toggleNotificationTray = jest.fn();
|
||||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
|
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
|
||||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||||
fireEvent.click(notificationCloseIconButton);
|
fireEvent.click(notificationCloseIconButton);
|
||||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||||
@@ -408,7 +422,7 @@ describe('Sequence', () => {
|
|||||||
|
|
||||||
it('does not render notification tray in sequence by default if in responsive view', async () => {
|
it('does not render notification tray in sequence by default if in responsive view', async () => {
|
||||||
global.innerWidth = breakpoints.medium.maxWidth;
|
global.innerWidth = breakpoints.medium.maxWidth;
|
||||||
const { container } = render(<Sequence {...mockData} />);
|
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||||
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
|
// 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');
|
expect(container).not.toHaveClass('notification-tray-container');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays loading message', () => {
|
it('displays loading message', () => {
|
||||||
render(<SequenceContent {...mockData} />);
|
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
|
||||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays messages for the locked content', async () => {
|
it('displays messages for the locked content', async () => {
|
||||||
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
|
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
|
||||||
const { container } = render(<SequenceContent {...mockData} gated />);
|
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
|
||||||
|
|
||||||
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
|
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('Content Locked')).toBeInTheDocument();
|
expect(await screen.findByText('Content Locked')).toBeInTheDocument();
|
||||||
@@ -38,7 +38,7 @@ describe('Sequence Content', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays message for no content', () => {
|
it('displays message for no content', () => {
|
||||||
render(<SequenceContent {...mockData} unitId={null} />);
|
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
|
||||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
|
|
||||||
import { Modal } from '@edx/paragon';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, {
|
|
||||||
Suspense, 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 PageLoading from '../../../generic/PageLoading';
|
|
||||||
import { fetchCourse } from '../../data';
|
|
||||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
|
||||||
import ShareButton from '../share/ShareButton';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
const HonorCode = React.lazy(() => import('./honor-code'));
|
|
||||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
|
||||||
*
|
|
||||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
|
||||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
|
||||||
* block that iframes external course content.
|
|
||||||
|
|
||||||
* This policy was selected in conference with the edX Security Working Group.
|
|
||||||
* Changes to it should be vetted by them (security@edx.org).
|
|
||||||
*/
|
|
||||||
const IFRAME_FEATURE_POLICY = (
|
|
||||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function useLoadBearingHook(id) {
|
|
||||||
const setValue = useState(0)[1];
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setValue(currentValue => currentValue + 1);
|
|
||||||
}, [id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function 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 Unit = ({
|
|
||||||
courseId,
|
|
||||||
format,
|
|
||||||
onLoaded,
|
|
||||||
id,
|
|
||||||
intl,
|
|
||||||
}) => {
|
|
||||||
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 [iframeHeight, setIframeHeight] = useState(0);
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
|
||||||
const [showError, setShowError] = useState(false);
|
|
||||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
|
||||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
|
||||||
|
|
||||||
const unit = useModel('units', id);
|
|
||||||
const course = useModel('coursewareMeta', courseId);
|
|
||||||
const {
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
userNeedsIntegritySignature,
|
|
||||||
} = course;
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
// Do not remove this hook. See function description.
|
|
||||||
useLoadBearingHook(id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userNeedsIntegritySignature && unit.graded) {
|
|
||||||
setShouldDisplayHonorCode(true);
|
|
||||||
} else {
|
|
||||||
setShouldDisplayHonorCode(false);
|
|
||||||
}
|
|
||||||
}, [userNeedsIntegritySignature]);
|
|
||||||
|
|
||||||
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);
|
|
||||||
useEffect(() => {
|
|
||||||
sendUrlHashToFrame(document.getElementById('unit-iframe'));
|
|
||||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="unit">
|
|
||||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
|
||||||
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
|
|
||||||
<BookmarkButton
|
|
||||||
unitId={unit.id}
|
|
||||||
isBookmarked={unit.bookmarked}
|
|
||||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
|
||||||
/>
|
|
||||||
{/* TODO: social share exp. Need to remove later */}
|
|
||||||
{(window.expSocialShareAboutUrls && window.expSocialShareAboutUrls[unit.id] !== undefined) && (
|
|
||||||
<ShareButton url={window.expSocialShareAboutUrls[unit.id]} />
|
|
||||||
)}
|
|
||||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
|
||||||
<Suspense
|
|
||||||
fallback={(
|
|
||||||
<PageLoading
|
|
||||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LockPaywall courseId={courseId} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{shouldDisplayHonorCode && (
|
|
||||||
<Suspense
|
|
||||||
fallback={(
|
|
||||||
<PageLoading
|
|
||||||
srMessage={intl.formatMessage(messages.loadingHonorCode)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HonorCode courseId={courseId} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{!shouldDisplayHonorCode && !hasLoaded && !showError && (
|
|
||||||
<PageLoading
|
|
||||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!shouldDisplayHonorCode && !hasLoaded && showError && (
|
|
||||||
<ErrorPage />
|
|
||||||
)}
|
|
||||||
{modalOptions.open && (
|
|
||||||
<Modal
|
|
||||||
body={(
|
|
||||||
<>
|
|
||||||
{modalOptions.body
|
|
||||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
|
||||||
: (
|
|
||||||
<iframe
|
|
||||||
title={modalOptions.title}
|
|
||||||
allow={IFRAME_FEATURE_POLICY}
|
|
||||||
frameBorder="0"
|
|
||||||
src={modalOptions.url}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100vh',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
onClose={() => { setModalOptions({ open: false }); }}
|
|
||||||
open
|
|
||||||
dialogClassName="modal-lti"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!shouldDisplayHonorCode && (
|
|
||||||
<div className="unit-iframe-wrapper">
|
|
||||||
<iframe
|
|
||||||
id="unit-iframe"
|
|
||||||
title={unit.title}
|
|
||||||
src={iframeUrl}
|
|
||||||
allow={IFRAME_FEATURE_POLICY}
|
|
||||||
allowFullScreen
|
|
||||||
height={iframeHeight}
|
|
||||||
scrolling="no"
|
|
||||||
referrerPolicy="origin"
|
|
||||||
onLoad={() => {
|
|
||||||
// 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.
|
|
||||||
if (!hasLoaded) {
|
|
||||||
setShowError(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onmessage = (e) => {
|
|
||||||
if (e.data.event_name) {
|
|
||||||
dispatch(processEvent(e.data, fetchCourse));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Unit.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
format: PropTypes.string,
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
onLoaded: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
Unit.defaultProps = {
|
|
||||||
format: null,
|
|
||||||
onLoaded: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(Unit);
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
114
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
114
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||||
|
*
|
||||||
|
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||||
|
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||||
|
* block that iframes external course content.
|
||||||
|
|
||||||
|
* 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 *'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const testIDs = StrictDict({
|
||||||
|
contentIFrame: 'content-iframe-test-id',
|
||||||
|
modalIFrame: 'modal-iframe-test-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContentIFrame = ({
|
||||||
|
iframeUrl,
|
||||||
|
shouldShowContent,
|
||||||
|
loadingMessage,
|
||||||
|
id,
|
||||||
|
elementId,
|
||||||
|
onLoaded,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
handleIFrameLoad,
|
||||||
|
hasLoaded,
|
||||||
|
iframeHeight,
|
||||||
|
showError,
|
||||||
|
} = hooks.useIFrameBehavior({
|
||||||
|
elementId,
|
||||||
|
id,
|
||||||
|
iframeUrl,
|
||||||
|
onLoaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
modalOptions,
|
||||||
|
handleModalClose,
|
||||||
|
} = hooks.useModalIFrameData();
|
||||||
|
|
||||||
|
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
|
||||||
|
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||||
|
: (
|
||||||
|
<iframe
|
||||||
|
title={modalOptions.title}
|
||||||
|
allow={IFRAME_FEATURE_POLICY}
|
||||||
|
frameBorder="0"
|
||||||
|
src={modalOptions.url}
|
||||||
|
style={{ width: '100%', height: modalOptions.height }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
dialogClassName="modal-lti"
|
||||||
|
onClose={handleModalClose}
|
||||||
|
open
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentIFrame.propTypes = {
|
||||||
|
iframeUrl: PropTypes.string,
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
shouldShowContent: PropTypes.bool.isRequired,
|
||||||
|
loadingMessage: PropTypes.node.isRequired,
|
||||||
|
elementId: PropTypes.string.isRequired,
|
||||||
|
onLoaded: PropTypes.func,
|
||||||
|
title: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentIFrame.defaultProps = {
|
||||||
|
iframeUrl: null,
|
||||||
|
onLoaded: () => ({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentIFrame;
|
||||||
175
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
175
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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 }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/courseware/course/sequence/Unit/UnitSuspense.jsx
Normal file
50
src/courseware/course/sequence/Unit/UnitSuspense.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
106
src/courseware/course/sequence/Unit/UnitSuspense.test.jsx
Normal file
106
src/courseware/course/sequence/Unit/UnitSuspense.test.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// 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>
|
||||||
|
`;
|
||||||
26
src/courseware/course/sequence/Unit/constants.js
Normal file
26
src/courseware/course/sequence/Unit/constants.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { StrictDict } from '@edx/react-unit-test-utils/dist';
|
||||||
|
|
||||||
|
export const modelKeys = StrictDict({
|
||||||
|
units: 'units',
|
||||||
|
coursewareMeta: 'coursewareMeta',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const views = StrictDict({
|
||||||
|
student: 'student_view',
|
||||||
|
public: 'public_view',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadingState = 'loading';
|
||||||
|
|
||||||
|
export const messageTypes = StrictDict({
|
||||||
|
modal: 'plugin.modal',
|
||||||
|
resize: 'plugin.resize',
|
||||||
|
videoFullScreen: 'plugin.videoFullScreen',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StrictDict({
|
||||||
|
modelKeys,
|
||||||
|
views,
|
||||||
|
loadingState,
|
||||||
|
messageTypes,
|
||||||
|
});
|
||||||
5
src/courseware/course/sequence/Unit/hooks/index.js
Normal file
5
src/courseware/course/sequence/Unit/hooks/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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';
|
||||||
37
src/courseware/course/sequence/Unit/hooks/useExamAccess.js
Normal file
37
src/courseware/course/sequence/Unit/hooks/useExamAccess.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Normal file
116
src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/courseware/course/sequence/Unit/index.jsx
Normal file
73
src/courseware/course/sequence/Unit/index.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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 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';
|
||||||
|
|
||||||
|
const Unit = ({
|
||||||
|
courseId,
|
||||||
|
format,
|
||||||
|
onLoaded,
|
||||||
|
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,
|
||||||
|
format,
|
||||||
|
examAccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="unit">
|
||||||
|
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||||
|
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
|
||||||
|
<BookmarkButton
|
||||||
|
unitId={unit.id}
|
||||||
|
isBookmarked={unit.bookmarked}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
/>
|
||||||
|
<UnitSuspense {...{ courseId, id }} />
|
||||||
|
<ContentIFrame
|
||||||
|
elementId="unit-iframe"
|
||||||
|
id={id}
|
||||||
|
iframeUrl={iframeUrl}
|
||||||
|
loadingMessage={formatMessage(messages.loadingSequence)}
|
||||||
|
onLoaded={onLoaded}
|
||||||
|
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
|
||||||
|
title={unit.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Unit.propTypes = {
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
format: PropTypes.string,
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
onLoaded: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
Unit.defaultProps = {
|
||||||
|
format: null,
|
||||||
|
onLoaded: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Unit;
|
||||||
191
src/courseware/course/sequence/Unit/index.test.jsx
Normal file
191
src/courseware/course/sequence/Unit/index.test.jsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/courseware/course/sequence/Unit/urls.js
Normal file
28
src/courseware/course/sequence/Unit/urls.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
42
src/courseware/course/sequence/Unit/urls.test.js
Normal file
42
src/courseware/course/sequence/Unit/urls.test.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 React, { useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -11,8 +11,9 @@ import messages from './messages';
|
|||||||
const ContentLock = ({
|
const ContentLock = ({
|
||||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
history.push(`/course/${courseId}/${prereqId}`);
|
navigate(`/course/${courseId}/${prereqId}`);
|
||||||
}, [courseId, prereqId]);
|
}, [courseId, prereqId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import {
|
import {
|
||||||
render, screen, fireEvent, initializeMockApp,
|
render, screen, fireEvent, initializeMockApp,
|
||||||
} from '../../../../setupTest';
|
} from '../../../../setupTest';
|
||||||
import ContentLock from './ContentLock';
|
import ContentLock from './ContentLock';
|
||||||
|
|
||||||
|
const mockNavigate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Content Lock', () => {
|
describe('Content Lock', () => {
|
||||||
const mockData = {
|
const mockData = {
|
||||||
courseId: 'test-course-id',
|
courseId: 'test-course-id',
|
||||||
@@ -19,7 +25,7 @@ describe('Content Lock', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays sequence title along with lock icon', () => {
|
it('displays sequence title along with lock icon', () => {
|
||||||
const { container } = render(<ContentLock {...mockData} />);
|
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
const lockIcon = container.querySelector('svg');
|
const lockIcon = container.querySelector('svg');
|
||||||
expect(lockIcon).toHaveClass('fa-lock');
|
expect(lockIcon).toHaveClass('fa-lock');
|
||||||
@@ -28,16 +34,15 @@ describe('Content Lock', () => {
|
|||||||
|
|
||||||
it('displays prerequisite name', () => {
|
it('displays prerequisite name', () => {
|
||||||
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
|
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
|
||||||
render(<ContentLock {...mockData} />);
|
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
expect(screen.getByText(prereqText)).toBeInTheDocument();
|
expect(screen.getByText(prereqText)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles click', () => {
|
it('handles click', () => {
|
||||||
history.push = jest.fn();
|
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||||
render(<ContentLock {...mockData} />);
|
|
||||||
fireEvent.click(screen.getByRole('button'));
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { getConfig, history } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { ActionRow, Alert, Button } from '@edx/paragon';
|
import { ActionRow, Alert, Button } from '@edx/paragon';
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
import { saveIntegritySignature } from '../../../data';
|
import { saveIntegritySignature } from '../../../data';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const HonorCode = ({ intl, courseId }) => {
|
const HonorCode = ({ intl, courseId }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const {
|
const {
|
||||||
isMasquerading,
|
isMasquerading,
|
||||||
@@ -20,7 +22,7 @@ const HonorCode = ({ intl, courseId }) => {
|
|||||||
const siteName = getConfig().SITE_NAME;
|
const siteName = getConfig().SITE_NAME;
|
||||||
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
|
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
|
||||||
|
|
||||||
const handleCancel = () => history.push(`/course/${courseId}/home`);
|
const handleCancel = () => navigate(`/course/${courseId}/home`);
|
||||||
|
|
||||||
const handleAgree = () => dispatch(
|
const handleAgree = () => dispatch(
|
||||||
// If the request is made by a staff user masquerading as a specific learner,
|
// If the request is made by a staff user masquerading as a specific learner,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getConfig, history } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
} from '../../../../setupTest';
|
} from '../../../../setupTest';
|
||||||
import HonorCode from './HonorCode';
|
import HonorCode from './HonorCode';
|
||||||
|
|
||||||
|
const mockNavigate = jest.fn();
|
||||||
|
|
||||||
initializeMockApp();
|
initializeMockApp();
|
||||||
jest.mock('@edx/frontend-platform', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('@edx/frontend-platform'),
|
...jest.requireActual('react-router-dom'),
|
||||||
history: {
|
useNavigate: () => mockNavigate,
|
||||||
push: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Honor Code', () => {
|
describe('Honor Code', () => {
|
||||||
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
|
|||||||
|
|
||||||
it('cancel button links to course home ', async () => {
|
it('cancel button links to course home ', async () => {
|
||||||
await setupStoreState();
|
await setupStoreState();
|
||||||
render(<HonorCode {...mockData} />);
|
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||||
const cancelButton = screen.getByText('Cancel');
|
const cancelButton = screen.getByText('Cancel');
|
||||||
fireEvent.click(cancelButton);
|
fireEvent.click(cancelButton);
|
||||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls to save integrity_signature when agreeing', async () => {
|
it('calls to save integrity_signature when agreeing', async () => {
|
||||||
await setupStoreState({ username: authenticatedUser.username });
|
await setupStoreState({ username: authenticatedUser.username });
|
||||||
render(<HonorCode {...mockData} />);
|
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||||
const agreeButton = screen.getByText('I agree');
|
const agreeButton = screen.getByText('I agree');
|
||||||
fireEvent.click(agreeButton);
|
fireEvent.click(agreeButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
|
|||||||
username: authenticatedUser.username,
|
username: authenticatedUser.username,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
render(<HonorCode {...mockData} />);
|
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||||
const agreeButton = screen.getByText('I agree');
|
const agreeButton = screen.getByText('I agree');
|
||||||
fireEvent.click(agreeButton);
|
fireEvent.click(agreeButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
|
|||||||
username: 'otheruser',
|
username: 'otheruser',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
render(<HonorCode {...mockData} />);
|
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||||
const agreeButton = screen.getByText('I agree');
|
const agreeButton = screen.getByText('I agree');
|
||||||
fireEvent.click(agreeButton);
|
fireEvent.click(agreeButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { breakpoints, Button, useWindowSize } from '@edx/paragon';
|
import { breakpoints, Button, useWindowSize } from '@edx/paragon';
|
||||||
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
|
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
|
||||||
@@ -26,12 +27,13 @@ const SequenceNavigation = ({
|
|||||||
sequenceId,
|
sequenceId,
|
||||||
className,
|
className,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
nextSequenceHandler,
|
nextHandler,
|
||||||
previousSequenceHandler,
|
previousHandler,
|
||||||
goToCourseExitPage,
|
|
||||||
}) => {
|
}) => {
|
||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
const {
|
||||||
|
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||||
|
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
sequenceStatus,
|
sequenceStatus,
|
||||||
@@ -63,27 +65,49 @@ const SequenceNavigation = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPreviousButton = () => {
|
||||||
|
const disabled = isFirstUnit;
|
||||||
|
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="previous-btn"
|
||||||
|
onClick={previousHandler}
|
||||||
|
disabled={disabled}
|
||||||
|
iconBefore={prevArrow}
|
||||||
|
as={disabled ? undefined : Link}
|
||||||
|
to={disabled ? undefined : previousLink}
|
||||||
|
>
|
||||||
|
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderNextButton = () => {
|
const renderNextButton = () => {
|
||||||
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
|
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
|
||||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
|
|
||||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||||
const disabled = isLastUnit && !exitActive;
|
const disabled = isLastUnit && !exitActive;
|
||||||
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
|
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="next-btn"
|
||||||
|
onClick={nextHandler}
|
||||||
|
disabled={disabled}
|
||||||
|
iconAfter={nextArrow}
|
||||||
|
as={disabled ? undefined : Link}
|
||||||
|
to={disabled ? undefined : nextLink}
|
||||||
|
>
|
||||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
|
||||||
|
|
||||||
return sequenceStatus === LOADED && (
|
return sequenceStatus === LOADED && (
|
||||||
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
|
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
|
||||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
{renderPreviousButton()}
|
||||||
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
|
||||||
</Button>
|
|
||||||
{renderUnitButtons()}
|
{renderUnitButtons()}
|
||||||
{renderNextButton()}
|
{renderNextButton()}
|
||||||
|
|
||||||
@@ -97,9 +121,8 @@ SequenceNavigation.propTypes = {
|
|||||||
unitId: PropTypes.string,
|
unitId: PropTypes.string,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
onNavigate: PropTypes.func.isRequired,
|
onNavigate: PropTypes.func.isRequired,
|
||||||
nextSequenceHandler: PropTypes.func.isRequired,
|
nextHandler: PropTypes.func.isRequired,
|
||||||
previousSequenceHandler: PropTypes.func.isRequired,
|
previousHandler: PropTypes.func.isRequired,
|
||||||
goToCourseExitPage: PropTypes.func.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SequenceNavigation.defaultProps = {
|
SequenceNavigation.defaultProps = {
|
||||||
|
|||||||
@@ -25,22 +25,21 @@ describe('Sequence Navigation', () => {
|
|||||||
mockData = {
|
mockData = {
|
||||||
unitId: unitBlocks[1].id,
|
unitId: unitBlocks[1].id,
|
||||||
sequenceId: courseware.sequenceId,
|
sequenceId: courseware.sequenceId,
|
||||||
previousSequenceHandler: () => {},
|
previousHandler: () => {},
|
||||||
onNavigate: () => {},
|
onNavigate: () => {},
|
||||||
nextSequenceHandler: () => {},
|
nextHandler: () => {},
|
||||||
goToCourseExitPage: () => {},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty while loading', async () => {
|
it('is empty while loading', async () => {
|
||||||
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
|
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
|
||||||
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
|
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty div without unitId', () => {
|
it('renders empty div without unitId', () => {
|
||||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
|
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
|
||||||
expect(getByText(container, (content, element) => (
|
expect(getByText(container, (content, element) => (
|
||||||
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
|
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
|
||||||
});
|
});
|
||||||
@@ -62,7 +61,7 @@ describe('Sequence Navigation', () => {
|
|||||||
sequenceId: sequenceBlocks[0].id,
|
sequenceId: sequenceBlocks[0].id,
|
||||||
onNavigate: jest.fn(),
|
onNavigate: jest.fn(),
|
||||||
};
|
};
|
||||||
render(<SequenceNavigation {...testData} />, { store: testStore });
|
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
|
|
||||||
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
|
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
|
||||||
fireEvent.click(unitButton);
|
fireEvent.click(unitButton);
|
||||||
@@ -75,27 +74,27 @@ describe('Sequence Navigation', () => {
|
|||||||
|
|
||||||
it('renders correctly and handles unit button clicks', () => {
|
it('renders correctly and handles unit button clicks', () => {
|
||||||
const onNavigate = jest.fn();
|
const onNavigate = jest.fn();
|
||||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
|
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
|
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
|
||||||
expect(unitButtons).toHaveLength(unitButtons.length);
|
expect(unitButtons).toHaveLength(unitButtons.length);
|
||||||
unitButtons.forEach(button => fireEvent.click(button));
|
unitButtons.forEach(button => fireEvent.click(button));
|
||||||
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
|
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
|
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
|
||||||
render(<SequenceNavigation {...mockData} />);
|
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
|
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
|
||||||
expect(button).toBeEnabled();
|
expect(button).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has the "Previous" button disabled for the first unit of the sequence', () => {
|
it('has the "Previous" button disabled for the first unit of the sequence', () => {
|
||||||
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
|
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
|
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
|
||||||
@@ -107,10 +106,10 @@ describe('Sequence Navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,11 +122,11 @@ describe('Sequence Navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||||
@@ -144,22 +143,22 @@ describe('Sequence Navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles "Previous" and "Next" click', () => {
|
it('handles "Previous" and "Next" click', () => {
|
||||||
const previousSequenceHandler = jest.fn();
|
const previousHandler = jest.fn();
|
||||||
const nextSequenceHandler = jest.fn();
|
const nextHandler = jest.fn();
|
||||||
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
|
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||||
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
|
expect(previousHandler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||||
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
|
expect(nextHandler).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,14 +40,17 @@ describe('Sequence Navigation Dropdown', () => {
|
|||||||
|
|
||||||
unitBlocks.forEach((unit, index) => {
|
unitBlocks.forEach((unit, index) => {
|
||||||
it(`marks unit ${index + 1} as active`, async () => {
|
it(`marks unit ${index + 1} as active`, async () => {
|
||||||
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
|
const { container } = render(
|
||||||
|
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
|
||||||
|
{ wrapWithRouter: true },
|
||||||
|
);
|
||||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await fireEvent.click(dropdownToggle);
|
await fireEvent.click(dropdownToggle);
|
||||||
});
|
});
|
||||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||||
// Only the current unit should be marked as active.
|
// Only the current unit should be marked as active.
|
||||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
|
||||||
if (button.textContent === unit.display_name) {
|
if (button.textContent === unit.display_name) {
|
||||||
expect(button).toHaveClass('active');
|
expect(button).toHaveClass('active');
|
||||||
} else {
|
} else {
|
||||||
@@ -59,14 +62,17 @@ describe('Sequence Navigation Dropdown', () => {
|
|||||||
|
|
||||||
it('handles the clicks', () => {
|
it('handles the clicks', () => {
|
||||||
const onNavigate = jest.fn();
|
const onNavigate = jest.fn();
|
||||||
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
|
const { container } = render(
|
||||||
|
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
|
||||||
|
{ wrapWithRouter: true },
|
||||||
|
);
|
||||||
|
|
||||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.click(dropdownToggle);
|
fireEvent.click(dropdownToggle);
|
||||||
});
|
});
|
||||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
|
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||||
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
|
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
|
||||||
unitBlocks.forEach((unit, index) => {
|
unitBlocks.forEach((unit, index) => {
|
||||||
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);
|
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);
|
||||||
|
|||||||
@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
|
|||||||
|
|
||||||
it('renders unit buttons', () => {
|
it('renders unit buttons', () => {
|
||||||
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
|
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
|
||||||
render(<SequenceNavigationTabs {...mockData} />);
|
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
|
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders unit buttons and dropdown button', async () => {
|
it('renders unit buttons and dropdown button', async () => {
|
||||||
let container = null;
|
let container = null;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
|
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
|
||||||
const booyah = render(<SequenceNavigationTabs {...mockData} />);
|
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||||
container = booyah.container;
|
container = booyah.container;
|
||||||
|
|
||||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||||
@@ -60,8 +60,8 @@ describe('Sequence Navigation Tabs', () => {
|
|||||||
await fireEvent.click(dropdownToggle);
|
await fireEvent.click(dropdownToggle);
|
||||||
});
|
});
|
||||||
const dropdownMenu = container.querySelector('.dropdown');
|
const dropdownMenu = container.querySelector('.dropdown');
|
||||||
const dropdownButtons = getAllByRole(dropdownMenu, 'button');
|
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
|
||||||
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
|
expect(dropdownButtons).toHaveLength(unitBlocks.length);
|
||||||
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
|
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
|
||||||
.toHaveClass('dropdown-toggle');
|
.toHaveClass('dropdown-toggle');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect, useSelector } from 'react-redux';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ const UnitButton = ({
|
|||||||
className,
|
className,
|
||||||
showTitle,
|
showTitle,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { courseId, sequenceId } = useSelector(state => state.courseware);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onClick(unitId);
|
onClick(unitId);
|
||||||
}, [onClick, unitId]);
|
}, [onClick, unitId]);
|
||||||
@@ -33,6 +36,8 @@ const UnitButton = ({
|
|||||||
variant="link"
|
variant="link"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={title}
|
title={title}
|
||||||
|
as={Link}
|
||||||
|
to={`/course/${courseId}/${sequenceId}/${unitId}`}
|
||||||
>
|
>
|
||||||
<UnitIcon type={contentType} />
|
<UnitIcon type={contentType} />
|
||||||
{showTitle && <span className="unit-title">{title}</span>}
|
{showTitle && <span className="unit-title">{title}</span>}
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ describe('Unit Button', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides title by default', () => {
|
it('hides title by default', () => {
|
||||||
render(<UnitButton {...mockData} />);
|
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
|
||||||
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
|
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows title', () => {
|
it('shows title', () => {
|
||||||
render(<UnitButton {...mockData} showTitle />);
|
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
|
||||||
expect(screen.getByRole('button')).toHaveTextContent(unit.display_name);
|
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show completion for non-completed unit', () => {
|
it('does not show completion for non-completed unit', () => {
|
||||||
@@ -49,7 +49,7 @@ describe('Unit Button', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows completion for completed unit', () => {
|
it('shows completion for completed unit', () => {
|
||||||
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
|
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
|
||||||
const buttonIcons = container.querySelectorAll('svg');
|
const buttonIcons = container.querySelectorAll('svg');
|
||||||
expect(buttonIcons).toHaveLength(2);
|
expect(buttonIcons).toHaveLength(2);
|
||||||
expect(buttonIcons[1]).toHaveClass('fa-check');
|
expect(buttonIcons[1]).toHaveClass('fa-check');
|
||||||
@@ -70,7 +70,7 @@ describe('Unit Button', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows bookmark', () => {
|
it('shows bookmark', () => {
|
||||||
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
|
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
|
||||||
const buttonIcons = container.querySelectorAll('svg');
|
const buttonIcons = container.querySelectorAll('svg');
|
||||||
expect(buttonIcons).toHaveLength(3);
|
expect(buttonIcons).toHaveLength(3);
|
||||||
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
|
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
|
||||||
@@ -78,8 +78,8 @@ describe('Unit Button', () => {
|
|||||||
|
|
||||||
it('handles the click', () => {
|
it('handles the click', () => {
|
||||||
const onClick = jest.fn();
|
const onClick = jest.fn();
|
||||||
render(<UnitButton {...mockData} onClick={onClick} />);
|
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
|
||||||
fireEvent.click(screen.getByRole('button'));
|
fireEvent.click(screen.getByRole('link'));
|
||||||
expect(onClick).toHaveBeenCalledTimes(1);
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -20,14 +21,32 @@ const UnitNavigation = ({
|
|||||||
unitId,
|
unitId,
|
||||||
onClickPrevious,
|
onClickPrevious,
|
||||||
onClickNext,
|
onClickNext,
|
||||||
goToCourseExitPage,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
const {
|
||||||
|
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||||
|
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||||
const { courseId } = useSelector(state => state.courseware);
|
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 renderNextButton = () => {
|
||||||
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
|
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
|
||||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
|
|
||||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||||
const disabled = isLastUnit && !exitActive;
|
const disabled = isLastUnit && !exitActive;
|
||||||
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
|
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
|
||||||
@@ -35,8 +54,10 @@ const UnitNavigation = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
className="next-button d-flex align-items-center justify-content-center"
|
className="next-button d-flex align-items-center justify-content-center"
|
||||||
onClick={buttonOnClick}
|
onClick={onClickNext}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
as={disabled ? undefined : Link}
|
||||||
|
to={disabled ? undefined : nextLink}
|
||||||
>
|
>
|
||||||
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
|
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
@@ -46,18 +67,9 @@ const UnitNavigation = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
|
|
||||||
return (
|
return (
|
||||||
<div className="unit-navigation d-flex">
|
<div className="unit-navigation d-flex">
|
||||||
<Button
|
{renderPreviousButton()}
|
||||||
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()}
|
{renderNextButton()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -69,7 +81,6 @@ UnitNavigation.propTypes = {
|
|||||||
unitId: PropTypes.string,
|
unitId: PropTypes.string,
|
||||||
onClickPrevious: PropTypes.func.isRequired,
|
onClickPrevious: PropTypes.func.isRequired,
|
||||||
onClickNext: PropTypes.func.isRequired,
|
onClickNext: PropTypes.func.isRequired,
|
||||||
goToCourseExitPage: PropTypes.func.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
UnitNavigation.defaultProps = {
|
UnitNavigation.defaultProps = {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ describe('Unit Navigation', () => {
|
|||||||
sequenceId: courseware.sequenceId,
|
sequenceId: courseware.sequenceId,
|
||||||
onClickPrevious: () => {},
|
onClickPrevious: () => {},
|
||||||
onClickNext: () => {},
|
onClickNext: () => {},
|
||||||
goToCourseExitPage: () => {},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,10 +32,10 @@ describe('Unit Navigation', () => {
|
|||||||
unitId=""
|
unitId=""
|
||||||
onClickPrevious={() => {}}
|
onClickPrevious={() => {}}
|
||||||
onClickNext={() => {}}
|
onClickNext={() => {}}
|
||||||
/>);
|
/>, { wrapWithRouter: true });
|
||||||
|
|
||||||
// Only "Previous" and "Next" buttons should be rendered.
|
// Only "Previous" and "Next" buttons should be rendered.
|
||||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles the clicks', () => {
|
it('handles the clicks', () => {
|
||||||
@@ -45,32 +44,30 @@ describe('Unit Navigation', () => {
|
|||||||
|
|
||||||
render(<UnitNavigation
|
render(<UnitNavigation
|
||||||
{...mockData}
|
{...mockData}
|
||||||
sequenceId=""
|
|
||||||
unitId=""
|
|
||||||
onClickPrevious={onClickPrevious}
|
onClickPrevious={onClickPrevious}
|
||||||
onClickNext={onClickNext}
|
onClickNext={onClickNext}
|
||||||
/>);
|
/>, { wrapWithRouter: true });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||||
expect(onClickPrevious).toHaveBeenCalledTimes(1);
|
expect(onClickPrevious).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||||
expect(onClickNext).toHaveBeenCalledTimes(1);
|
expect(onClickNext).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
|
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
|
||||||
render(<UnitNavigation {...mockData} />);
|
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
screen.getAllByRole('button').forEach(button => {
|
screen.getAllByRole('link').forEach(button => {
|
||||||
expect(button).toBeEnabled();
|
expect(button).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has the "Previous" button disabled for the first unit in the sequence', () => {
|
it('has the "Previous" button disabled for the first unit in the sequence', () => {
|
||||||
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
|
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
|
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
|
||||||
@@ -82,10 +79,10 @@ describe('Unit Navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,11 +95,11 @@ describe('Unit Navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||||
@@ -119,10 +116,10 @@ describe('Unit Navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||||
{ store: testStore },
|
{ store: testStore, wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sequenceIdsSelector } from '../../../data';
|
|||||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||||
const sequence = useModel('sequences', currentSequenceId);
|
const sequence = useModel('sequences', currentSequenceId);
|
||||||
|
const courseId = useSelector(state => state.courseware.courseId);
|
||||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||||
|
|
||||||
@@ -14,12 +15,43 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
|||||||
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
||||||
return { isFirstUnit: false, isLastUnit: false };
|
return { isFirstUnit: false, isLastUnit: false };
|
||||||
}
|
}
|
||||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
|
||||||
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
|
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||||
|
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||||
|
|
||||||
|
const isFirstSequence = sequenceIndex === 0;
|
||||||
|
const isFirstUnitInSequence = unitIndex === 0;
|
||||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||||
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
|
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
|
||||||
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
|
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
|
||||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||||
|
|
||||||
return { isFirstUnit, isLastUnit };
|
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||||
|
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||||
|
|
||||||
|
let nextLink;
|
||||||
|
if (isLastUnit) {
|
||||||
|
nextLink = `/course/${courseId}/course-end`;
|
||||||
|
} else {
|
||||||
|
const nextIndex = unitIndex + 1;
|
||||||
|
if (nextIndex < sequence.unitIds.length) {
|
||||||
|
const nextUnitId = sequence.unitIds[nextIndex];
|
||||||
|
nextLink = `/course/${courseId}/${currentSequenceId}/${nextUnitId}`;
|
||||||
|
} else if (nextSequenceId) {
|
||||||
|
nextLink = `/course/${courseId}/${nextSequenceId}/first`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousLink;
|
||||||
|
const previousIndex = unitIndex - 1;
|
||||||
|
if (previousIndex >= 0) {
|
||||||
|
const previousUnitId = sequence.unitIds[previousIndex];
|
||||||
|
previousLink = `/course/${courseId}/${currentSequenceId}/${previousUnitId}`;
|
||||||
|
} else if (previousSequenceId) {
|
||||||
|
previousLink = `/course/${courseId}/${previousSequenceId}/last`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
||||||
import { getSessionStorage } from '../../../data/sessionStorage';
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
|
||||||
import SidebarContext from './SidebarContext';
|
import SidebarContext from './SidebarContext';
|
||||||
import { SIDEBARS } from './sidebars';
|
import { SIDEBARS } from './sidebars';
|
||||||
|
|
||||||
@@ -15,32 +13,18 @@ const SidebarProvider = ({
|
|||||||
unitId,
|
unitId,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
|
||||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||||
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
|
|
||||||
const query = new URLSearchParams(window.location.search);
|
const query = new URLSearchParams(window.location.search);
|
||||||
if (query.get('sidebar') === 'true') {
|
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
|
||||||
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 [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// As a one-off set initial sidebar if the verified mode data has just loaded
|
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID);
|
||||||
if (verifiedMode && currentSidebar === null && initialSidebar) {
|
|
||||||
setCurrentSidebar(initialSidebar);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initialSidebar, verifiedMode]);
|
}, [unitId]);
|
||||||
|
|
||||||
const onNotificationSeen = useCallback(() => {
|
const onNotificationSeen = useCallback(() => {
|
||||||
setNotificationStatus('inactive');
|
setNotificationStatus('inactive');
|
||||||
@@ -49,11 +33,6 @@ const SidebarProvider = ({
|
|||||||
|
|
||||||
const toggleSidebar = useCallback((sidebarId) => {
|
const toggleSidebar = useCallback((sidebarId) => {
|
||||||
// Switch to new sidebar or hide the current sidebar
|
// 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);
|
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
|
||||||
}, [currentSidebar]);
|
}, [currentSidebar]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|||||||
import { Icon } from '@edx/paragon';
|
import { Icon } from '@edx/paragon';
|
||||||
import { QuestionAnswer } from '@edx/paragon/icons';
|
import { QuestionAnswer } from '@edx/paragon/icons';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React, { useContext, useEffect } from 'react';
|
import React, { useContext, useEffect, useMemo } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useModel } from '../../../../../generic/model-store';
|
import { useModel } from '../../../../../generic/model-store';
|
||||||
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
||||||
@@ -23,12 +23,16 @@ const DiscussionsTrigger = ({
|
|||||||
courseId,
|
courseId,
|
||||||
} = useContext(SidebarContext);
|
} = useContext(SidebarContext);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { tabs } = useModel('courseHomeMeta', courseId);
|
||||||
const topic = useModel('discussionTopics', unitId);
|
const topic = useModel('discussionTopics', unitId);
|
||||||
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
|
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
|
||||||
|
const edxProvider = useMemo(
|
||||||
|
() => tabs?.find(tab => tab.slug === 'discussion'),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch the topic data if the MFE is configured.
|
if (baseUrl && edxProvider) {
|
||||||
if (baseUrl) {
|
|
||||||
dispatch(getCourseDiscussionTopics(courseId));
|
dispatch(getCourseDiscussionTopics(courseId));
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ Factory.define('courseMetadata')
|
|||||||
related_programs: null,
|
related_programs: null,
|
||||||
user_needs_integrity_signature: false,
|
user_needs_integrity_signature: false,
|
||||||
recommendations: null,
|
recommendations: null,
|
||||||
|
learning_assistant_enabled: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ function normalizeMetadata(metadata) {
|
|||||||
relatedPrograms: camelCaseObject(data.related_programs),
|
relatedPrograms: camelCaseObject(data.related_programs),
|
||||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||||
canAccessProctoredExams: data.can_access_proctored_exams,
|
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 path from 'path';
|
||||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||||
|
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCourseMetadata,
|
getCourseMetadata,
|
||||||
@@ -19,8 +19,8 @@ import {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
somethingLike: like, term, boolean, string, eachLike, integer,
|
somethingLike: like, term, boolean, string, eachLike, integer,
|
||||||
} = Matchers;
|
} = MatchersV3;
|
||||||
const provider = new Pact({
|
const provider = new PactV3({
|
||||||
consumer: 'frontend-app-learning',
|
consumer: 'frontend-app-learning',
|
||||||
provider: 'lms',
|
provider: 'lms',
|
||||||
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
|
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
|
||||||
@@ -33,37 +33,13 @@ const provider = new Pact({
|
|||||||
describe('Courseware Service', () => {
|
describe('Courseware Service', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
initializeMockApp();
|
initializeMockApp();
|
||||||
await provider
|
mergeConfig({
|
||||||
.setup()
|
LMS_BASE_URL: 'http://localhost:8081',
|
||||||
.then((options) => mergeConfig({
|
}, 'Custom app config for pact tests');
|
||||||
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', () => {
|
describe('When a request to get a learning sequence outline is made', () => {
|
||||||
it('returns a normalized outline', async () => {
|
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 = {
|
const normalizedOutline = {
|
||||||
courses: {
|
courses: {
|
||||||
'course-v1:edX+DemoX+Demo_Course': {
|
'course-v1:edX+DemoX+Demo_Course': {
|
||||||
@@ -76,74 +52,32 @@ describe('Courseware Service', () => {
|
|||||||
sections: {},
|
sections: {},
|
||||||
sequences: {},
|
sequences: {},
|
||||||
};
|
};
|
||||||
const response = await getLearningSequencesOutline(courseId);
|
setTimeout(() => {
|
||||||
expect(response).toEqual(normalizedOutline);
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips unreleased sequences', async () => {
|
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 = {
|
const normalizedOutline = {
|
||||||
courses: {
|
courses: {
|
||||||
'course-v1:edX+DemoX+Demo_Course': {
|
'course-v1:edX+DemoX+Demo_Course': {
|
||||||
@@ -179,120 +113,78 @@ describe('Courseware Service', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const response = await getLearningSequencesOutline(courseId);
|
setTimeout(() => {
|
||||||
expect(response).toEqual(normalizedOutline);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get course metadata is made', () => {
|
describe('When a request to get course metadata is made', () => {
|
||||||
it('returns normalized course metadata', async () => {
|
it('returns normalized course metadata', () => {
|
||||||
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 = {
|
const normalizedCourseMetadata = {
|
||||||
accessExpiration: {
|
accessExpiration: {
|
||||||
expirationDate: '2013-02-05T05:00:00Z',
|
expirationDate: '2013-02-05T05:00:00Z',
|
||||||
@@ -336,57 +228,125 @@ describe('Courseware Service', () => {
|
|||||||
linkedinAddToProfileUrl: null,
|
linkedinAddToProfileUrl: null,
|
||||||
relatedPrograms: null,
|
relatedPrograms: null,
|
||||||
userNeedsIntegritySignature: false,
|
userNeedsIntegritySignature: false,
|
||||||
|
learningAssistantEnabled: false,
|
||||||
};
|
};
|
||||||
const response = await getCourseMetadata(courseId);
|
setTimeout(() => {
|
||||||
expect(response).toBeTruthy();
|
provider.addInteraction({
|
||||||
expect(response).toEqual(normalizedCourseMetadata);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get sequence metadata is made', () => {
|
describe('When a request to get sequence metadata is made', () => {
|
||||||
it('returns normalized sequence metadata ', async () => {
|
it('returns normalized sequence metadata ', () => {
|
||||||
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 = {
|
const normalizedSequenceMetadata = {
|
||||||
sequence: {
|
sequence: {
|
||||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||||
@@ -423,102 +383,154 @@ describe('Courseware Service', () => {
|
|||||||
containsContentTypeGatedContent: false,
|
containsContentTypeGatedContent: false,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
const response = await getSequenceMetadata(sequenceId);
|
setTimeout(() => {
|
||||||
expect(response).toBeTruthy();
|
provider.addInteraction({
|
||||||
expect(response).toEqual(normalizedSequenceMetadata);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to set sequence position against Unit Index is made', () => {
|
describe('When a request to set sequence position against Unit Index is made', () => {
|
||||||
it('returns if the request was success or failure', async () => {
|
it('returns if the request was success or failure', async () => {
|
||||||
await provider.addInteraction({
|
setTimeout(() => {
|
||||||
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
provider.addInteraction({
|
||||||
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
||||||
withRequest: {
|
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
||||||
method: 'POST',
|
withRequest: {
|
||||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
|
method: 'POST',
|
||||||
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
|
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,
|
||||||
const response = await postSequencePosition(courseId, sequenceId, 0);
|
body: {
|
||||||
expect(response).toBeTruthy();
|
success: boolean(true),
|
||||||
expect(response).toEqual({ success: true });
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = postSequencePosition(courseId, sequenceId, 0);
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
expect(response).toEqual({ success: true });
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get completion block is made', () => {
|
describe('When a request to get completion block is made', () => {
|
||||||
it('returns the completion status', async () => {
|
it('returns the completion status', async () => {
|
||||||
await provider.addInteraction({
|
setTimeout(() => {
|
||||||
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
provider.addInteraction({
|
||||||
uponReceiving: 'a request to get completion block',
|
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
||||||
withRequest: {
|
uponReceiving: 'a request to get completion block',
|
||||||
method: 'POST',
|
withRequest: {
|
||||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
method: 'POST',
|
||||||
body: { usage_key: usageId },
|
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
||||||
},
|
body: { usage_key: usageId },
|
||||||
willRespondWith: {
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
complete: boolean(true),
|
|
||||||
},
|
},
|
||||||
},
|
willRespondWith: {
|
||||||
});
|
status: 200,
|
||||||
const response = await getBlockCompletion(courseId, sequenceId, usageId);
|
body: {
|
||||||
expect(response).toBeTruthy();
|
complete: boolean(true),
|
||||||
expect(response).toEqual(true);
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = getBlockCompletion(courseId, sequenceId, usageId);
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
expect(response).toEqual(true);
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get resume block is made', () => {
|
describe('When a request to get resume block is made', () => {
|
||||||
it('returns block id, section id and unit id of the resume block', async () => {
|
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 = {
|
const camelCaseResponse = {
|
||||||
blockId: '642fadf46d074aabb637f20af320fb31',
|
blockId: '642fadf46d074aabb637f20af320fb31',
|
||||||
sectionId: '642fadf46d074aabb637f20af320fb87',
|
sectionId: '642fadf46d074aabb637f20af320fb87',
|
||||||
unitId: '642fadf46d074aabb637f20af320fb99',
|
unitId: '642fadf46d074aabb637f20af320fb99',
|
||||||
};
|
};
|
||||||
const response = await getResumeBlock(courseId);
|
setTimeout(() => {
|
||||||
expect(response).toBeTruthy();
|
provider.addInteraction({
|
||||||
expect(response).toEqual(camelCaseResponse);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to send activation email is made', () => {
|
describe('When a request to send activation email is made', () => {
|
||||||
it('returns status code 200', async () => {
|
it('returns status code 200', () => {
|
||||||
await provider.addInteraction({
|
setTimeout(() => {
|
||||||
state: 'A logged-in user may or may not be active',
|
provider.addInteraction({
|
||||||
uponReceiving: 'a request to send activation email',
|
state: 'A logged-in user may or may not be active',
|
||||||
withRequest: {
|
uponReceiving: 'a request to send activation email',
|
||||||
method: 'POST',
|
withRequest: {
|
||||||
path: '/api/send_account_activation_email',
|
method: 'POST',
|
||||||
},
|
path: '/api/send_account_activation_email',
|
||||||
willRespondWith: {
|
},
|
||||||
status: 200,
|
willRespondWith: {
|
||||||
},
|
status: 200,
|
||||||
});
|
},
|
||||||
const response = await sendActivationEmail();
|
});
|
||||||
expect(response).toEqual('');
|
const response = sendActivationEmail();
|
||||||
|
expect(response).toEqual('');
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
22
src/courseware/utils.jsx
Normal file
22
src/courseware/utils.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
const withParamsAndNavigation = WrappedComponent => {
|
||||||
|
const WithParamsNavigationComponent = props => {
|
||||||
|
const { courseId, sequenceId, unitId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<WrappedComponent
|
||||||
|
routeCourseId={courseId}
|
||||||
|
routeSequenceId={sequenceId}
|
||||||
|
routeUnitId={unitId}
|
||||||
|
navigate={navigate}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return WithParamsNavigationComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withParamsAndNavigation;
|
||||||
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
||||||
<div>
|
<div>
|
||||||
PageRoute: {
|
PageWrap: {
|
||||||
"computedMatch": {
|
"children": [
|
||||||
"path": "/course/:courseId/home",
|
" ",
|
||||||
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
|
[
|
||||||
"isExact": true,
|
" ",
|
||||||
"params": {
|
[],
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course"
|
" "
|
||||||
}
|
],
|
||||||
}
|
" "
|
||||||
|
]
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PageRoute } from '@edx/frontend-platform/react';
|
import { PageWrap } from '@edx/frontend-platform/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHistory, generatePath } from 'react-router';
|
import {
|
||||||
|
generatePath, useMatch, Navigate,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DECODE_ROUTES } from '../constants';
|
||||||
|
|
||||||
|
const ROUTES = [].concat(
|
||||||
|
...Object.values(DECODE_ROUTES).map(value => (Array.isArray(value) ? value : [value])),
|
||||||
|
);
|
||||||
|
|
||||||
export const decodeUrl = (encodedUrl) => {
|
export const decodeUrl = (encodedUrl) => {
|
||||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||||
@@ -11,10 +19,16 @@ export const decodeUrl = (encodedUrl) => {
|
|||||||
return decodeUrl(decodedUrl);
|
return decodeUrl(decodedUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DecodePageRoute = (props) => {
|
const DecodePageRoute = ({ children }) => {
|
||||||
const history = useHistory();
|
let computedMatch = null;
|
||||||
if (props.computedMatch) {
|
|
||||||
const { url, path, params } = props.computedMatch;
|
ROUTES.forEach((route) => {
|
||||||
|
const matchedRoute = useMatch(route);
|
||||||
|
if (matchedRoute) { computedMatch = matchedRoute; }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (computedMatch) {
|
||||||
|
const { pathname, pattern, params } = computedMatch;
|
||||||
|
|
||||||
Object.keys(params).forEach((param) => {
|
Object.keys(params).forEach((param) => {
|
||||||
// only decode params not the entire url.
|
// only decode params not the entire url.
|
||||||
@@ -22,28 +36,19 @@ const DecodePageRoute = (props) => {
|
|||||||
params[param] = decodeUrl(params[param]);
|
params[param] = decodeUrl(params[param]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const newUrl = generatePath(path, params);
|
const newUrl = generatePath(pattern.path, params);
|
||||||
|
|
||||||
// if the url get decoded, reroute to the decoded url
|
// if the url get decoded, reroute to the decoded url
|
||||||
if (newUrl !== url) {
|
if (newUrl !== pathname) {
|
||||||
history.replace(newUrl);
|
return <Navigate to={newUrl} replace />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageRoute {...props} />;
|
return <PageWrap> {children} </PageWrap>;
|
||||||
};
|
};
|
||||||
|
|
||||||
DecodePageRoute.propTypes = {
|
DecodePageRoute.propTypes = {
|
||||||
computedMatch: PropTypes.shape({
|
children: PropTypes.node.isRequired,
|
||||||
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;
|
export default DecodePageRoute;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { createMemoryHistory } from 'history';
|
import {
|
||||||
import { Router, matchPath } from 'react-router';
|
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
|
||||||
|
} from 'react-router-dom';
|
||||||
import DecodePageRoute, { decodeUrl } from '.';
|
import DecodePageRoute, { decodeUrl } from '.';
|
||||||
|
|
||||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
@@ -15,84 +16,90 @@ const deepEncodedCourseId = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/react', () => ({
|
jest.mock('@edx/frontend-platform/react', () => ({
|
||||||
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
|
PageWrap: (props) => `PageWrap: ${JSON.stringify(props, null, 2)}`,
|
||||||
|
}));
|
||||||
|
jest.mock('../constants', () => ({
|
||||||
|
DECODE_ROUTES: {
|
||||||
|
MOCK_ROUTE_1: '/course/:courseId/home',
|
||||||
|
MOCK_ROUTE_2: `/course/:courseId/${encodeURIComponent('some+thing')}/:unitId`,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderPage = (props) => {
|
jest.mock('react-router-dom', () => {
|
||||||
const memHistory = createMemoryHistory({
|
const mockNavigation = jest.fn();
|
||||||
initialEntries: [props?.path],
|
|
||||||
});
|
|
||||||
|
|
||||||
const history = {
|
// eslint-disable-next-line react/prop-types
|
||||||
...memHistory,
|
const Navigate = ({ to }) => {
|
||||||
replace: jest.fn(),
|
mockNavigation(to);
|
||||||
|
return <div />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
Navigate,
|
||||||
|
mockNavigate: mockNavigation,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderPage = (props) => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Router history={history}>
|
<Router initialEntries={[props?.pathname]}>
|
||||||
<DecodePageRoute computedMatch={props} />
|
<Routes>
|
||||||
|
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
|
||||||
|
</Routes>
|
||||||
</Router>,
|
</Router>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return { container };
|
||||||
container,
|
|
||||||
history,
|
|
||||||
props,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('DecodePageRoute', () => {
|
describe('DecodePageRoute', () => {
|
||||||
it('should not modify the url if it does not need to be decoded', () => {
|
afterEach(() => {
|
||||||
const props = matchPath(`/course/${decodedCourseId}/home`, {
|
mockNavigate.mockClear();
|
||||||
path: '/course/:courseId/home',
|
});
|
||||||
});
|
|
||||||
const { container, history } = renderPage(props);
|
|
||||||
|
|
||||||
expect(props.url).toContain(decodedCourseId);
|
it('should not modify the url if it does not need to be decoded', () => {
|
||||||
expect(history.replace).not.toHaveBeenCalled();
|
const props = matchPath({
|
||||||
|
path: '/course/:courseId/home',
|
||||||
|
}, `/course/${decodedCourseId}/home`);
|
||||||
|
const { container } = renderPage(props);
|
||||||
|
|
||||||
|
expect(props.pathname).toContain(decodedCourseId);
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decode the url and replace the history if necessary', () => {
|
it('should decode the url and replace the history if necessary', () => {
|
||||||
const props = matchPath(`/course/${encodedCourseId}/home`, {
|
const props = matchPath({
|
||||||
path: '/course/:courseId/home',
|
path: '/course/:courseId/home',
|
||||||
});
|
}, `/course/${encodedCourseId}/home`);
|
||||||
const { history } = renderPage(props);
|
renderPage(props);
|
||||||
|
|
||||||
expect(props.url).not.toContain(decodedCourseId);
|
expect(props.pathname).not.toContain(decodedCourseId);
|
||||||
expect(props.url).toContain(encodedCourseId);
|
expect(props.pathname).toContain(encodedCourseId);
|
||||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decode the url multiple times if necessary', () => {
|
it('should decode the url multiple times if necessary', () => {
|
||||||
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
|
const props = matchPath({
|
||||||
path: '/course/:courseId/home',
|
path: '/course/:courseId/home',
|
||||||
});
|
}, `/course/${deepEncodedCourseId}/home`);
|
||||||
const { history } = renderPage(props);
|
renderPage(props);
|
||||||
|
|
||||||
expect(props.url).not.toContain(decodedCourseId);
|
expect(props.pathname).not.toContain(decodedCourseId);
|
||||||
expect(props.url).toContain(deepEncodedCourseId);
|
expect(props.pathname).toContain(deepEncodedCourseId);
|
||||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only decode the url params and not the entire url', () => {
|
it('should only decode the url params and not the entire url', () => {
|
||||||
const decodedUnitId = 'some+thing';
|
const decodedUnitId = 'some+thing';
|
||||||
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
||||||
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
|
const props = matchPath({
|
||||||
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
||||||
});
|
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
|
||||||
const { history } = renderPage(props);
|
renderPage(props);
|
||||||
|
|
||||||
const decodedUrls = history.replace.mock.calls[0][0].split('/');
|
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
|
||||||
|
|
||||||
// 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,9 +1,8 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||||
import Footer from '@edx/frontend-component-footer';
|
import Footer from '@edx/frontend-component-footer';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, Navigate } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { Redirect } from 'react-router';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert';
|
import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert';
|
||||||
import { AlertList } from './user-messages';
|
import { AlertList } from './user-messages';
|
||||||
@@ -38,7 +37,7 @@ const CourseAccessErrorPage = ({ intl }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (courseStatus === LOADED) {
|
if (courseStatus === LOADED) {
|
||||||
return (<Redirect to={`/redirect/home/${courseId}`} />);
|
return <Navigate to={`/redirect/home/${courseId}`} replace />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { history } from '@edx/frontend-platform';
|
import { history } from '@edx/frontend-platform';
|
||||||
import { Route } from 'react-router';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { initializeTestStore, render, screen } from '../setupTest';
|
import { initializeTestStore, render, screen } from '../setupTest';
|
||||||
import CourseAccessErrorPage from './CourseAccessErrorPage';
|
import CourseAccessErrorPage from './CourseAccessErrorPage';
|
||||||
|
|
||||||
const mockDispatch = jest.fn();
|
const mockDispatch = jest.fn();
|
||||||
|
const mockNavigate = jest.fn();
|
||||||
let mockCourseStatus;
|
let mockCourseStatus;
|
||||||
|
|
||||||
jest.mock('react-redux', () => ({
|
jest.mock('react-redux', () => ({
|
||||||
...jest.requireActual('react-redux'),
|
...jest.requireActual('react-redux'),
|
||||||
useDispatch: () => mockDispatch,
|
useDispatch: () => mockDispatch,
|
||||||
@@ -14,6 +16,10 @@ jest.mock('react-redux', () => ({
|
|||||||
jest.mock('./PageLoading', () => function () {
|
jest.mock('./PageLoading', () => function () {
|
||||||
return <div data-testid="page-loading" />;
|
return <div data-testid="page-loading" />;
|
||||||
});
|
});
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...(jest.requireActual('react-router-dom')),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('CourseAccessErrorPage', () => {
|
describe('CourseAccessErrorPage', () => {
|
||||||
let courseId;
|
let courseId;
|
||||||
@@ -28,33 +34,36 @@ describe('CourseAccessErrorPage', () => {
|
|||||||
it('Displays loading in start on page rendering', () => {
|
it('Displays loading in start on page rendering', () => {
|
||||||
mockCourseStatus = 'loading';
|
mockCourseStatus = 'loading';
|
||||||
render(
|
render(
|
||||||
<Route path="/course/:courseId/access-denied">
|
<Routes>
|
||||||
<CourseAccessErrorPage />
|
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||||
</Route>,
|
</Routes>,
|
||||||
|
{ wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
|
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
|
||||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Redirect user to homepage if user has access', () => {
|
it('Redirect user to homepage if user has access', () => {
|
||||||
mockCourseStatus = 'loaded';
|
mockCourseStatus = 'loaded';
|
||||||
render(
|
render(
|
||||||
<Route path="/course/:courseId/access-denied">
|
<Routes>
|
||||||
<CourseAccessErrorPage />
|
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||||
</Route>,
|
</Routes>,
|
||||||
|
{ wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('For access denied it should render access denied page', () => {
|
it('For access denied it should render access denied page', () => {
|
||||||
mockCourseStatus = 'denied';
|
mockCourseStatus = 'denied';
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Route path="/course/:courseId/access-denied">
|
<Routes>
|
||||||
<CourseAccessErrorPage />
|
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||||
</Route>,
|
</Routes>,
|
||||||
|
{ wrapWithRouter: true },
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
|
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
|
||||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Redirect, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
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
|
// 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.
|
// present for our cases, and I believe it's the only one we use normally.
|
||||||
if (location.pathname.includes(' ')) {
|
if (location.pathname.includes(' ') || location.pathname.includes('%20')) {
|
||||||
const newLocation = {
|
const newLocation = {
|
||||||
...location,
|
...location,
|
||||||
pathname: location.pathname.replaceAll(' ', '+'),
|
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
|
||||||
};
|
};
|
||||||
|
|
||||||
sendTrackEvent('edx.ui.lms.path_fixed', {
|
sendTrackEvent('edx.ui.lms.path_fixed', {
|
||||||
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
|
|||||||
search: location.search,
|
search: location.search,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (<Redirect to={newLocation} />);
|
return (<Navigate to={newLocation} replace />);
|
||||||
}
|
}
|
||||||
|
|
||||||
return children; // pass through
|
return children; // pass through
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MemoryRouter, Route } from 'react-router-dom';
|
import {
|
||||||
|
MemoryRouter, Route, Routes, useLocation,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
|
|
||||||
@@ -19,16 +21,20 @@ describe('PathFixesProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function buildAndRender(path) {
|
function buildAndRender(path) {
|
||||||
|
const LocationComponent = () => {
|
||||||
|
testLocation = useLocation();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<PathFixesProvider>
|
<PathFixesProvider>
|
||||||
<Route
|
<Routes>
|
||||||
path="*"
|
<Route
|
||||||
render={routeProps => {
|
path="*"
|
||||||
testLocation = routeProps.location;
|
element={<LocationComponent />}
|
||||||
return null;
|
/>
|
||||||
}}
|
</Routes>
|
||||||
/>
|
|
||||||
</PathFixesProvider>
|
</PathFixesProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||||
|
import { messages as headerMessages } from '@edx/frontend-component-header';
|
||||||
|
import { messages as paragonMessages } from '@edx/paragon';
|
||||||
|
|
||||||
import arMessages from './messages/ar.json';
|
import arMessages from './messages/ar.json';
|
||||||
import frMessages from './messages/fr.json';
|
import frMessages from './messages/fr.json';
|
||||||
import es419Messages from './messages/es_419.json';
|
import es419Messages from './messages/es_419.json';
|
||||||
@@ -8,10 +12,14 @@ import ukMessages from './messages/uk.json';
|
|||||||
import deMessages from './messages/de.json';
|
import deMessages from './messages/de.json';
|
||||||
import ruMessages from './messages/ru.json';
|
import ruMessages from './messages/ru.json';
|
||||||
import hiMessages from './messages/hi.json';
|
import hiMessages from './messages/hi.json';
|
||||||
|
import faIRMessages from './messages/fa_IR.json';
|
||||||
import frCAMessages from './messages/fr_CA.json';
|
import frCAMessages from './messages/fr_CA.json';
|
||||||
|
import dedeCAMessages from './messages/de_DE.json';
|
||||||
|
import ititCAMessages from './messages/it_IT.json';
|
||||||
|
import ptptCAMessages from './messages/pt_PT.json';
|
||||||
// no need to import en messages-- they are in the defaultMessage field
|
// no need to import en messages-- they are in the defaultMessage field
|
||||||
|
|
||||||
const messages = {
|
const appMessages = {
|
||||||
ar: arMessages,
|
ar: arMessages,
|
||||||
'es-419': es419Messages,
|
'es-419': es419Messages,
|
||||||
fr: frMessages,
|
fr: frMessages,
|
||||||
@@ -20,9 +28,18 @@ const messages = {
|
|||||||
it: itMessages,
|
it: itMessages,
|
||||||
de: deMessages,
|
de: deMessages,
|
||||||
hi: hiMessages,
|
hi: hiMessages,
|
||||||
|
'fa-ir': faIRMessages,
|
||||||
'fr-ca': frCAMessages,
|
'fr-ca': frCAMessages,
|
||||||
ru: ruMessages,
|
ru: ruMessages,
|
||||||
uk: ukMessages,
|
uk: ukMessages,
|
||||||
|
'de-de': dedeCAMessages,
|
||||||
|
'it-it': ititCAMessages,
|
||||||
|
'pt-pt': ptptCAMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default messages;
|
export default [
|
||||||
|
paragonMessages,
|
||||||
|
appMessages,
|
||||||
|
footerMessages,
|
||||||
|
headerMessages,
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user