Compare commits
93 Commits
ilee2u/bug
...
bw/fronten
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfe91653d1 | ||
|
|
afa1e7bcc9 | ||
|
|
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 | ||
|
|
7317c9424a | ||
|
|
d897663b73 | ||
|
|
2e4eb158f2 | ||
|
|
35b229bd1b | ||
|
|
4ebd569792 | ||
|
|
52235ebc1c | ||
|
|
aa380e8619 | ||
|
|
4cf0c7f4d7 | ||
|
|
743650a99e | ||
|
|
39d89bee9e | ||
|
|
a601e431b2 | ||
|
|
7519bbe28e | ||
|
|
4b90dcbfc3 | ||
|
|
54cb52cb6d | ||
|
|
6dbd3f49dd | ||
|
|
678502bb40 | ||
|
|
bf77fc7ca1 | ||
|
|
421a9a5d2b | ||
|
|
dfe44cae56 | ||
|
|
a88571dae8 | ||
|
|
a4ea334692 | ||
|
|
97a1cb4ffc | ||
|
|
5166bfe056 | ||
|
|
33e3765b19 | ||
|
|
a13e7d7389 | ||
|
|
a4ea1b54a4 | ||
|
|
cd430ebb5d | ||
|
|
630d44a8cc |
3
.env
3
.env
@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
INSIGHTS_BASE_URL=''
|
||||
@@ -28,6 +29,8 @@ LOGO_WHITE_URL=''
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
SEGMENT_KEY=''
|
||||
|
||||
@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
@@ -28,6 +29,8 @@ LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
@@ -43,3 +46,5 @@ TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
|
||||
@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
@@ -28,6 +29,8 @@ LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
@@ -42,3 +45,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
12
.github/workflows/commitlint.yml
vendored
12
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,14 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: remove tsconfig.json # see issue https://github.com/conventional-changelog/commitlint/issues/3256
|
||||
run: |
|
||||
rm -f tsconfig.json
|
||||
- name: Check commits
|
||||
uses: wagoid/commitlint-github-action@v5
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
7
.github/workflows/validate.yml
vendored
7
.github/workflows/validate.yml
vendored
@@ -9,14 +9,13 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
20
Makefile
20
Makefile
@@ -1,6 +1,7 @@
|
||||
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
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
@@ -42,9 +43,24 @@ push_translations:
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
|
||||
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -61,5 +77,5 @@ validate:
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
npm ci
|
||||
npm ci --legacy-peer-deps
|
||||
make validate
|
||||
|
||||
@@ -9,6 +9,12 @@ module.exports = createConfig('jest', {
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
// see https://github.com/axios/axios/issues/5026
|
||||
moduleNameMapper: {
|
||||
"^axios$": "axios/dist/axios.js",
|
||||
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
|
||||
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
testEnvironment: 'jsdom'
|
||||
});
|
||||
|
||||
45270
package-lock.json
generated
45270
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@@ -29,52 +29,55 @@
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "11.1.0",
|
||||
"@edx/frontend-component-header": "^3.6.0",
|
||||
"@edx/frontend-lib-special-exams": "^2.2.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "^20.24.0",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/frontend-component-footer": "12.1.2",
|
||||
"@edx/frontend-component-header": "4.4.4",
|
||||
"@edx/frontend-lib-learning-assistant": "^1.11.1",
|
||||
"@edx/frontend-lib-special-exams": "2.20.1",
|
||||
"@edx/frontend-platform": "4.6.0",
|
||||
"@edx/paragon": "20.46.0",
|
||||
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.18",
|
||||
"@popperjs/core": "2.11.6",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "^5.3.0",
|
||||
"js-cookie": "3.0.1",
|
||||
"history": "5.3.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.1",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-share": "^4.4.1",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.7",
|
||||
"reselect": "4.1.8",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.1.1",
|
||||
"@edx/frontend-build": "^12.4.15",
|
||||
"@edx/reactifex": "2.1.1",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "12.9.0-alpha.6",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios": "^1.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"es-check": "6.2.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.5.1",
|
||||
"jest": "29.5.0",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
id="learning.outline.alert.start.long"
|
||||
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
@@ -88,7 +88,7 @@ const CourseStartAlert = ({ payload }) => {
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.calendar"
|
||||
id="learning.outline.alert.start.calendar"
|
||||
defaultMessage="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. "
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
course_access_redirect: false,
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
|
||||
@@ -18,6 +18,9 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
@@ -336,6 +339,9 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
@@ -532,6 +538,9 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
|
||||
@@ -204,12 +204,18 @@ export async function getDatesTabData(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 401) {
|
||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -259,7 +265,7 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
@@ -269,6 +275,12 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -322,7 +334,20 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const requestTime = Date.now();
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||
|
||||
import {
|
||||
getCourseHomeCourseMetadata,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
|
||||
const {
|
||||
somethingLike: like, term, boolean, string, eachLike,
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
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', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, 'Custom app config for pact tests');
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
describe('When a request to fetch tab is made', () => {
|
||||
it('returns tab data for a course_id', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
},
|
||||
course_id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
is_enrolled: boolean(true),
|
||||
is_self_paced: boolean(false),
|
||||
is_staff: boolean(true),
|
||||
number: string('DemoX'),
|
||||
org: string('edX'),
|
||||
original_user_is_staff: boolean(true),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
tab_id: 'courseware',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to fetch dates tab is made', () => {
|
||||
it('returns course date blocks for a course_id', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
complete: null,
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learner_has_access: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extra_info: null,
|
||||
first_component_block_id: '',
|
||||
}),
|
||||
has_ended: boolean(false),
|
||||
learner_is_full_access: boolean(true),
|
||||
user_timezone: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
datesBannerInfo: {
|
||||
missedDeadlines: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
courseDateBlocks: [
|
||||
{
|
||||
assignmentType: null,
|
||||
complete: null,
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
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.',
|
||||
learner_has_access: true,
|
||||
learnerHasAccess: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
linkText: '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,
|
||||
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);
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
const response = getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,18 @@ describe('Data layer integration tests', () => {
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
const courseHomeAccessDeniedMetadata = Factory.build(
|
||||
'courseHomeMetadata',
|
||||
{
|
||||
id: courseId,
|
||||
course_access: {
|
||||
has_access: false,
|
||||
error_code: 'bad codes',
|
||||
additional_context_user_message: 'your Codes Are BAD',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -55,16 +67,40 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state).toMatchSnapshot({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 401 || errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test fetchOutlineTab', () => {
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
axiosMock.onGet(outlineUrl).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
@@ -75,8 +111,6 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
||||
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
@@ -84,8 +118,31 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state).toMatchSnapshot({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'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', () => {
|
||||
@@ -113,7 +170,14 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state).toMatchSnapshot({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
@@ -129,6 +193,19 @@ describe('Data layer integration tests', () => {
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.targetUserId).toEqual(2);
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}`;
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('denied');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
|
||||
@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
|
||||
// Click to collapse section
|
||||
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 () => {
|
||||
|
||||
@@ -39,6 +39,50 @@ const SequenceLink = ({
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
const dueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-set"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const noDueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-not-set"
|
||||
defaultMessage="{description}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
@@ -70,31 +114,11 @@ const SequenceLink = ({
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
{due && (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{due ? dueDateMessage : noDueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -243,7 +243,7 @@ class CoursewareContainer extends Component {
|
||||
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
|
||||
}
|
||||
|
||||
handleUnitNavigationClick = (nextUnitId) => {
|
||||
handleUnitNavigationClick = () => {
|
||||
const {
|
||||
courseId, sequenceId,
|
||||
match: {
|
||||
@@ -254,21 +254,17 @@ class CoursewareContainer extends Component {
|
||||
} = this.props;
|
||||
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
};
|
||||
|
||||
handleNextSequenceClick = () => {
|
||||
const {
|
||||
course,
|
||||
courseId,
|
||||
nextSequence,
|
||||
sequence,
|
||||
sequenceId,
|
||||
} = this.props;
|
||||
|
||||
if (nextSequence !== null) {
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/first`);
|
||||
|
||||
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
|
||||
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
|
||||
handleNextSectionCelebration(sequenceId, nextSequence.id);
|
||||
@@ -276,12 +272,7 @@ class CoursewareContainer extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
handlePreviousSequenceClick = () => {
|
||||
const { previousSequence, courseId } = this.props;
|
||||
if (previousSequence !== null) {
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/last`);
|
||||
}
|
||||
};
|
||||
handlePreviousSequenceClick = () => {};
|
||||
|
||||
render() {
|
||||
const {
|
||||
|
||||
@@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { UserMessagesProvider } from '../generic/user-messages';
|
||||
import tabMessages from '../tab-page/messages';
|
||||
import { initializeMockApp } from '../setupTest';
|
||||
import { initializeMockApp, waitFor } from '../setupTest';
|
||||
|
||||
import CoursewareContainer from './CoursewareContainer';
|
||||
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
||||
@@ -185,7 +185,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
function assertSequenceNavigation(container, expectedUnitCount = 3) {
|
||||
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
|
||||
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
|
||||
|
||||
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
|
||||
@@ -211,7 +211,7 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -234,7 +234,7 @@ describe('CoursewareContainer', () => {
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -284,7 +284,7 @@ describe('CoursewareContainer', () => {
|
||||
describe('when the URL does not contain a unit ID', () => {
|
||||
it('should choose a unit within the section\'s first sequence', async () => {
|
||||
setUrl(sectionTree[1].id);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
||||
@@ -359,7 +359,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -378,7 +378,7 @@ describe('CoursewareContainer', () => {
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -395,7 +395,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should load the specified unit', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -411,12 +411,12 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
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];
|
||||
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}`);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import queryString from 'query-string';
|
||||
import PageLoading from '../generic/PageLoading';
|
||||
|
||||
import DecodePageRoute from '../decode-page-route';
|
||||
|
||||
const CoursewareRedirectLandingPage = () => {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
@@ -21,7 +23,7 @@ const CoursewareRedirectLandingPage = () => {
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<PageRoute
|
||||
<DecodePageRoute
|
||||
path={`${path}/survey/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
|
||||
@@ -40,7 +42,7 @@ const CoursewareRedirectLandingPage = () => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
<DecodePageRoute
|
||||
path={`${path}/home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`/course/${match.params.courseId}/home`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -10,6 +10,7 @@ import { AlertList } from '../../generic/user-messages';
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import Chat from './chat/Chat';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
@@ -43,10 +44,8 @@ const Course = ({
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(
|
||||
shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSection, dispatch, celebrations),
|
||||
);
|
||||
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false);
|
||||
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
|
||||
// the weekly goal celebration modal.
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
@@ -68,6 +67,17 @@ const Course = ({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
|
||||
courseId,
|
||||
sequenceId,
|
||||
celebrateFirstSection,
|
||||
dispatch,
|
||||
celebrations,
|
||||
));
|
||||
}, [sequenceId]);
|
||||
|
||||
return (
|
||||
<SidebarProvider courseId={courseId} unitId={unitId}>
|
||||
<Helmet>
|
||||
@@ -82,7 +92,16 @@ const Course = ({
|
||||
unitId={unitId}
|
||||
/>
|
||||
{shouldDisplayTriggers && (
|
||||
<SidebarTriggers />
|
||||
<>
|
||||
<Chat
|
||||
enabled={course.learningAssistantEnabled}
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||
/>
|
||||
<SidebarTriggers />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import {
|
||||
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import Course from './Course';
|
||||
import { executeThunk } from '../../utils';
|
||||
import * as thunks from '../data/thunks';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
@@ -43,6 +49,25 @@ describe('Course', () => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
const setupDiscussionSidebar = async () => {
|
||||
const testStore = await initializeTestStore({ provider: 'openedx' });
|
||||
const state = testStore.getState();
|
||||
const { courseware: { courseId } } = state;
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(200, { provider: 'openedx' });
|
||||
const topicsResponse = buildTopicsFromUnits(state.models.units);
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
|
||||
.reply(200, topicsResponse);
|
||||
|
||||
await executeThunk(thunks.getCourseDiscussionTopics(courseId), testStore.dispatch);
|
||||
const [firstUnitId] = Object.keys(state.models.units);
|
||||
mockData.unitId = firstUnitId;
|
||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
||||
mockData.sequenceId = firstSequenceId;
|
||||
|
||||
await render(<Course {...mockData} />, { store: testStore });
|
||||
};
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />);
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
@@ -107,9 +132,51 @@ describe('Course', () => {
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
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 () => {
|
||||
@@ -117,10 +184,10 @@ describe('Course', () => {
|
||||
render(<Course {...mockData} />);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
@@ -213,8 +280,8 @@ describe('Course', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,57 +1,80 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHome } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { SelectMenu } from '@edx/paragon';
|
||||
import { useToggle, ModalPopup, Menu } from '@edx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useModel, useModels } from '../../generic/model-store';
|
||||
import JumpNavMenuItem from './JumpNavMenuItem';
|
||||
|
||||
const CourseBreadcrumb = ({
|
||||
content, withSeparator, courseId, sequenceId, unitId, isStaff,
|
||||
content,
|
||||
withSeparator,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId,
|
||||
isStaff,
|
||||
}) => {
|
||||
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
|
||||
const 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 (
|
||||
<>
|
||||
{withSeparator && (
|
||||
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
|
||||
)}
|
||||
|
||||
<li style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
<li
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
data-testid="breadcrumb-item"
|
||||
>
|
||||
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
|
||||
? (
|
||||
<Link
|
||||
className="text-primary-500"
|
||||
to={defaultContent.sequences.length
|
||||
{showRegularLink ? (
|
||||
<Link
|
||||
className="text-primary-500"
|
||||
to={
|
||||
defaultContent.sequences.length
|
||||
? `/course/${courseId}/${defaultContent.sequences[0].id}`
|
||||
: `/course/${courseId}/${defaultContent.id}`}
|
||||
>
|
||||
{defaultContent.label}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<SelectMenu isLink defaultMessage={defaultContent.label}>
|
||||
{content.map(item => (
|
||||
<JumpNavMenuItem
|
||||
isDefault={item.default}
|
||||
sequences={item.sequences}
|
||||
courseId={courseId}
|
||||
title={item.label}
|
||||
currentSequence={sequenceId}
|
||||
currentUnit={unitId}
|
||||
/>
|
||||
))}
|
||||
</SelectMenu>
|
||||
)}
|
||||
|
||||
: `/course/${courseId}/${defaultContent.id}`
|
||||
}
|
||||
>
|
||||
{defaultContent.label}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{
|
||||
// eslint-disable-next-line
|
||||
<a className="text-primary-500" onClick={open} ref={setTarget}>
|
||||
{defaultContent.label}
|
||||
</a>
|
||||
}
|
||||
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
|
||||
<Menu>
|
||||
{content.map((item) => (
|
||||
<JumpNavMenuItem
|
||||
isDefault={item.default}
|
||||
sequences={item.sequences}
|
||||
courseId={courseId}
|
||||
title={item.label}
|
||||
currentSequence={sequenceId}
|
||||
currentUnit={unitId}
|
||||
onClick={close}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</ModalPopup>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
@@ -87,14 +110,21 @@ const CourseBreadcrumbs = ({
|
||||
isStaff,
|
||||
}) => {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const courseStatus = useSelector((state) => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(
|
||||
(state) => state.courseware.sequenceStatus,
|
||||
);
|
||||
|
||||
const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
|
||||
default: section.id === sectionId,
|
||||
title: section.title,
|
||||
sequences: useModels('sequences', section.sequenceIds),
|
||||
}]));
|
||||
const allSequencesInSections = Object.fromEntries(
|
||||
useModels('sections', course.sectionIds).map((section) => [
|
||||
section.id,
|
||||
{
|
||||
default: section.id === sectionId,
|
||||
title: section.title,
|
||||
sequences: useModels('sequences', section.sequenceIds),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const links = useMemo(() => {
|
||||
const chapters = [];
|
||||
@@ -108,7 +138,7 @@ const CourseBreadcrumbs = ({
|
||||
sequences: section.sequences,
|
||||
});
|
||||
if (section.default) {
|
||||
section.sequences.forEach(sequence => {
|
||||
section.sequences.forEach((sequence) => {
|
||||
sequentials.push({
|
||||
id: sequence.id,
|
||||
label: sequence.title,
|
||||
@@ -124,7 +154,7 @@ const CourseBreadcrumbs = ({
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
|
||||
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
|
||||
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
|
||||
<li className="list-unstyled col-auto m-0 p-0">
|
||||
<Link
|
||||
className="flex-shrink-0 text-primary"
|
||||
@@ -138,7 +168,7 @@ const CourseBreadcrumbs = ({
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
{links.map(content => (
|
||||
{links.map((content) => (
|
||||
<CourseBreadcrumb
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
|
||||
@@ -123,6 +123,6 @@ describe('CourseBreadcrumbs', () => {
|
||||
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
|
||||
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { MenuItem } from '@edx/paragon';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
sendTrackingLogEvent,
|
||||
@@ -15,6 +15,7 @@ const JumpNavMenuItem = ({
|
||||
currentUnit,
|
||||
sequences,
|
||||
isDefault,
|
||||
onClick,
|
||||
}) => {
|
||||
function logEvent(targetUrl) {
|
||||
const eventName = 'edx.ui.lms.jump_nav.selected';
|
||||
@@ -34,19 +35,20 @@ const JumpNavMenuItem = ({
|
||||
}
|
||||
return `/course/${courseId}/${sequences[0].id}`;
|
||||
}
|
||||
function handleClick() {
|
||||
function handleClick(e) {
|
||||
const url = destinationUrl();
|
||||
logEvent(url);
|
||||
history.push(url);
|
||||
if (onClick) { onClick(e); }
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
defaultSelected={isDefault}
|
||||
onClick={() => handleClick()}
|
||||
<Dropdown.Item
|
||||
active={isDefault}
|
||||
onClick={e => handleClick(e)}
|
||||
>
|
||||
{title}
|
||||
</MenuItem>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,6 +56,10 @@ const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
});
|
||||
|
||||
JumpNavMenuItem.defaultProps = {
|
||||
onClick: null,
|
||||
};
|
||||
|
||||
JumpNavMenuItem.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
|
||||
@@ -61,6 +67,7 @@ JumpNavMenuItem.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
currentSequence: PropTypes.string.isRequired,
|
||||
currentUnit: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default JumpNavMenuItem;
|
||||
|
||||
@@ -22,6 +22,7 @@ const mockData = {
|
||||
},
|
||||
],
|
||||
isDefault: false,
|
||||
onClick: jest.fn().mockName('onClick'),
|
||||
};
|
||||
describe('JumpNavMenuItem', () => {
|
||||
render(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
||||
} from '../../../setupTest';
|
||||
import { BookmarkButton } from './index';
|
||||
import { getBookmarksBaseUrl } from './data/api';
|
||||
|
||||
describe('Bookmark Button', () => {
|
||||
let axiosMock;
|
||||
@@ -32,7 +32,8 @@ describe('Bookmark Button', () => {
|
||||
mockData.unitId = nonBookmarkedUnitBlock.id;
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
const bookmarkUrl = getBookmarksBaseUrl();
|
||||
|
||||
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
||||
|
||||
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
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) {
|
||||
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
|
||||
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
|
||||
}
|
||||
|
||||
export async function deleteBookmark(usageId) {
|
||||
const { username } = getAuthenticatedUser();
|
||||
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
|
||||
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ClapsMobile from './assets/claps_280x201.gif';
|
||||
import ClapsTablet from './assets/claps_456x328.gif';
|
||||
import messages from './messages';
|
||||
@@ -19,12 +20,13 @@ import { useModel } from '../../../generic/model-store';
|
||||
const CelebrationModal = ({
|
||||
courseId, intl, isOpen, onClose, ...rest
|
||||
}) => {
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { org, celebrations } = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
recordFirstSectionCelebration(org, courseId);
|
||||
recordFirstSectionCelebration(org, courseId, celebrations, dispatch);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -15,9 +15,20 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId) {
|
||||
});
|
||||
}
|
||||
|
||||
function recordFirstSectionCelebration(org, courseId) {
|
||||
function recordFirstSectionCelebration(org, courseId, celebrations, dispatch) {
|
||||
// Tell the LMS
|
||||
postCelebrationComplete(courseId, { first_section: false });
|
||||
// Update our local copy of course data from LMS
|
||||
dispatch(updateModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
celebrations: {
|
||||
...celebrations,
|
||||
firstSection: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Tell our analytics
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
15
src/courseware/course/celebration/utils.test.jsx
Normal file
15
src/courseware/course/celebration/utils.test.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { recordFirstSectionCelebration } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('./data/api');
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(() => ({ administrator: 'admin' })),
|
||||
}));
|
||||
|
||||
describe('recordFirstSectionCelebration', () => {
|
||||
it('updates the local copy of the course data from the LMS', async () => {
|
||||
const dispatchMock = jest.fn();
|
||||
recordFirstSectionCelebration('org', 'courseId', 'celebration', dispatchMock);
|
||||
expect(dispatchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
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 PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import Calculator from './calculator';
|
||||
import NotesVisibility from './notes-visibility';
|
||||
|
||||
const ContentTools = ({
|
||||
course,
|
||||
}) => (
|
||||
<div className="content-tools">
|
||||
<div className="d-flex justify-content-end align-items-end m-0">
|
||||
{course.showCalculator && (
|
||||
<Calculator />
|
||||
)}
|
||||
{course.notes.enabled && (
|
||||
<NotesVisibility course={course} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) => {
|
||||
const {
|
||||
sidebarIsOpen,
|
||||
} = useSelector(state => state.learningAssistant);
|
||||
|
||||
return (
|
||||
!sidebarIsOpen && (
|
||||
<div className="content-tools">
|
||||
<div className="d-flex justify-content-end align-items-end m-0">
|
||||
{course.showCalculator && (
|
||||
<Calculator />
|
||||
)}
|
||||
{course.notes.enabled && (
|
||||
<NotesVisibility course={course} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
ContentTools.propTypes = {
|
||||
course: PropTypes.shape({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.content-tools {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
|
||||
@@ -25,7 +25,7 @@ class NotesVisibility extends Component {
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
const data = { visibility: this.state.visible };
|
||||
const data = { visibility: !this.state.visible };
|
||||
getAuthenticatedHttpClient().put(
|
||||
this.visibilityUrl,
|
||||
data,
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('Notes Visibility', () => {
|
||||
|
||||
expect(axiosMock.history.put).toHaveLength(1);
|
||||
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
|
||||
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
|
||||
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${!mockData.course.notes.visible}}`);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
@@ -139,9 +138,6 @@ const Sequence = ({
|
||||
}
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
const goToCourseExitPage = () => {
|
||||
history.push(`/course/${courseId}/course-end`);
|
||||
};
|
||||
|
||||
const defaultContent = (
|
||||
<div className="sequence-container d-inline-flex flex-row">
|
||||
@@ -150,7 +146,7 @@ const Sequence = ({
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextSequenceHandler={() => {
|
||||
nextHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
@@ -158,11 +154,10 @@ const Sequence = ({
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousSequenceHandler={() => {
|
||||
previousHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||
|
||||
@@ -186,7 +181,6 @@ const Sequence = ({
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('Sequence', () => {
|
||||
|
||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly for gated content', async () => {
|
||||
@@ -74,8 +75,10 @@ describe('Sequence', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Active`, `Next` and `Prerequisite` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(4);
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Active` and `Next` buttons.
|
||||
expect(screen.getAllByRole('link').length).toEqual(2);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
@@ -112,6 +115,7 @@ describe('Sequence', () => {
|
||||
|
||||
// No normal content or navigation should be rendered. Just the above alert.
|
||||
expect(screen.queryAllByRole('button').length).toEqual(0);
|
||||
expect(screen.queryAllByRole('link').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('displays error message on sequence load failure', async () => {
|
||||
@@ -125,13 +129,16 @@ describe('Sequence', () => {
|
||||
it('handles loading unit', async () => {
|
||||
render(<Sequence {...mockData} />);
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// Renders navigation buttons plus one button for each unit.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3 + unitBlocks.length);
|
||||
// `Previous`, `Bookmark` and `Close Tray` buttons
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
// Renders `Next` button plus one button for each unit.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
// At this point there will be 2 `Previous` and 2 `Next` buttons.
|
||||
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
|
||||
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
@@ -163,7 +170,7 @@ describe('Sequence', () => {
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
fireEvent.click(sequencePreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -176,7 +183,7 @@ describe('Sequence', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
|
||||
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
|
||||
.filter(button => button !== sequencePreviousButton)[0];
|
||||
fireEvent.click(unitPreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
@@ -199,7 +206,7 @@ describe('Sequence', () => {
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
|
||||
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
|
||||
fireEvent.click(sequenceNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
|
||||
@@ -211,7 +218,7 @@ describe('Sequence', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
|
||||
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
|
||||
.filter(button => button !== sequenceNextButton)[0];
|
||||
fireEvent.click(unitNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
@@ -237,11 +244,11 @@ describe('Sequence', () => {
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
|
||||
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
|
||||
// Therefore the next unit will still be `the initial one + 1`.
|
||||
@@ -323,11 +330,11 @@ describe('Sequence', () => {
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
@@ -370,7 +377,7 @@ describe('Sequence', () => {
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
|
||||
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
|
||||
current_tab: currentTabNumber,
|
||||
|
||||
@@ -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.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' }}
|
||||
/>
|
||||
)}
|
||||
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;
|
||||
174
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
174
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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: {
|
||||
open: false,
|
||||
},
|
||||
withBody: {
|
||||
body: 'test-body',
|
||||
open: true,
|
||||
},
|
||||
withUrl: {
|
||||
open: true,
|
||||
title: 'test-modal-title',
|
||||
url: 'test-modal-url',
|
||||
},
|
||||
};
|
||||
|
||||
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('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('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 open: false', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(Modal).length).toEqual(0);
|
||||
});
|
||||
describe('if modalOptions.open', () => {
|
||||
const testModalOpenAndHandleClose = () => {
|
||||
test('Modal component is open, 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: '100vh' }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
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,33 @@
|
||||
import React from 'react';
|
||||
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
|
||||
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
modalOptions: 'modalOptions',
|
||||
});
|
||||
|
||||
const useModalIFrameBehavior = () => {
|
||||
const [modalOptions, setModalOptions] = useKeyedState(stateKeys.modalOptions, ({ open: false }));
|
||||
|
||||
const receiveMessage = React.useCallback(({ data }) => {
|
||||
const { type, payload } = data;
|
||||
if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
}
|
||||
}, []);
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOptions({ open: false });
|
||||
};
|
||||
|
||||
return {
|
||||
handleModalClose,
|
||||
modalOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export default useModalIFrameBehavior;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { useEventListener } from '../../../../../generic/hooks';
|
||||
import { messageTypes } from '../constants';
|
||||
|
||||
import useModalIFrameBehavior, { stateKeys } 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 modalOptions to closed', () => {
|
||||
useModalIFrameBehavior();
|
||||
state.expectInitializedWith(stateKeys.modalOptions, { open: false });
|
||||
});
|
||||
describe('eventListener', () => {
|
||||
it('consumes modal events and opens sets modal options with open: true', () => {
|
||||
useModalIFrameBehavior();
|
||||
expect(useEventListener).toHaveBeenCalled();
|
||||
const { cb, prereqs } = useEventListener.mock.calls[0][1];
|
||||
expect(prereqs).toEqual([]);
|
||||
const payload = { test: 'values' };
|
||||
cb({ data: { type: messageTypes.modal, payload } });
|
||||
expect(state.setState.modalOptions).toHaveBeenCalledWith({ ...payload, open: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
test('handleModalClose sets modal options to closed', () => {
|
||||
useModalIFrameBehavior().handleModalClose();
|
||||
state.expectSetStateCalledWith(stateKeys.modalOptions, { open: false });
|
||||
});
|
||||
it('forwards modalOptions from state value', () => {
|
||||
const modalOptions = { test: 'options' };
|
||||
state.mockVal(stateKeys.modalOptions, modalOptions);
|
||||
expect(useModalIFrameBehavior().modalOptions).toEqual(modalOptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { breakpoints, Button, useWindowSize } from '@edx/paragon';
|
||||
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
|
||||
@@ -26,12 +27,13 @@ const SequenceNavigation = ({
|
||||
sequenceId,
|
||||
className,
|
||||
onNavigate,
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
goToCourseExitPage,
|
||||
nextHandler,
|
||||
previousHandler,
|
||||
}) => {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
courseId,
|
||||
sequenceStatus,
|
||||
@@ -63,27 +65,49 @@ const SequenceNavigation = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreviousButton = () => {
|
||||
const disabled = isFirstUnit;
|
||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="previous-btn"
|
||||
onClick={previousHandler}
|
||||
disabled={disabled}
|
||||
iconBefore={prevArrow}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : previousLink}
|
||||
>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNextButton = () => {
|
||||
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
|
||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
|
||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||
const disabled = isLastUnit && !exitActive;
|
||||
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="next-btn"
|
||||
onClick={nextHandler}
|
||||
disabled={disabled}
|
||||
iconAfter={nextArrow}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderPreviousButton()}
|
||||
{renderUnitButtons()}
|
||||
{renderNextButton()}
|
||||
|
||||
@@ -97,9 +121,8 @@ SequenceNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
nextHandler: PropTypes.func.isRequired,
|
||||
previousHandler: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
|
||||
@@ -25,10 +25,9 @@ describe('Sequence Navigation', () => {
|
||||
mockData = {
|
||||
unitId: unitBlocks[1].id,
|
||||
sequenceId: courseware.sequenceId,
|
||||
previousSequenceHandler: () => {},
|
||||
previousHandler: () => {},
|
||||
onNavigate: () => {},
|
||||
nextSequenceHandler: () => {},
|
||||
goToCourseExitPage: () => {},
|
||||
nextHandler: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -77,7 +76,7 @@ describe('Sequence Navigation', () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
|
||||
|
||||
const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
|
||||
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
|
||||
expect(unitButtons).toHaveLength(unitButtons.length);
|
||||
unitButtons.forEach(button => fireEvent.click(button));
|
||||
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
|
||||
@@ -86,7 +85,7 @@ describe('Sequence Navigation', () => {
|
||||
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
|
||||
render(<SequenceNavigation {...mockData} />);
|
||||
|
||||
screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
|
||||
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -95,7 +94,7 @@ describe('Sequence Navigation', () => {
|
||||
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
|
||||
|
||||
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 () => {
|
||||
@@ -110,7 +109,7 @@ describe('Sequence Navigation', () => {
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -126,8 +125,8 @@ describe('Sequence Navigation', () => {
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||
@@ -147,19 +146,19 @@ describe('Sequence Navigation', () => {
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('handles "Previous" and "Next" click', () => {
|
||||
const previousSequenceHandler = jest.fn();
|
||||
const nextSequenceHandler = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
|
||||
const previousHandler = jest.fn();
|
||||
const nextHandler = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(previousHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
expect(nextHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
// Only the current unit should be marked as active.
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === unit.display_name) {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
@@ -66,7 +66,7 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
|
||||
unitBlocks.forEach((unit, index) => {
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('Sequence Navigation Tabs', () => {
|
||||
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
|
||||
render(<SequenceNavigationTabs {...mockData} />);
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
|
||||
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
|
||||
});
|
||||
|
||||
it('renders unit buttons and dropdown button', async () => {
|
||||
@@ -60,8 +60,8 @@ describe('Sequence Navigation Tabs', () => {
|
||||
await fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown');
|
||||
const dropdownButtons = getAllByRole(dropdownMenu, 'button');
|
||||
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
|
||||
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
|
||||
expect(dropdownButtons).toHaveLength(unitBlocks.length);
|
||||
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
|
||||
.toHaveClass('dropdown-toggle');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
@@ -20,6 +21,8 @@ const UnitButton = ({
|
||||
className,
|
||||
showTitle,
|
||||
}) => {
|
||||
const { courseId, sequenceId } = useSelector(state => state.courseware);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(unitId);
|
||||
}, [onClick, unitId]);
|
||||
@@ -33,6 +36,8 @@ const UnitButton = ({
|
||||
variant="link"
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
as={Link}
|
||||
to={`/course/${courseId}/${sequenceId}/${unitId}`}
|
||||
>
|
||||
<UnitIcon type={contentType} />
|
||||
{showTitle && <span className="unit-title">{title}</span>}
|
||||
|
||||
@@ -33,12 +33,12 @@ describe('Unit Button', () => {
|
||||
|
||||
it('hides title by default', () => {
|
||||
render(<UnitButton {...mockData} />);
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
|
||||
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
|
||||
});
|
||||
|
||||
it('shows title', () => {
|
||||
render(<UnitButton {...mockData} showTitle />);
|
||||
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', () => {
|
||||
@@ -79,7 +79,7 @@ describe('Unit Button', () => {
|
||||
it('handles the click', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<UnitButton {...mockData} onClick={onClick} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -20,14 +21,32 @@ const UnitNavigation = ({
|
||||
unitId,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
goToCourseExitPage,
|
||||
}) => {
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
|
||||
const renderPreviousButton = () => {
|
||||
const disabled = isFirstUnit;
|
||||
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
|
||||
return (
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="previous-button mr-2 d-flex align-items-center justify-content-center"
|
||||
disabled={disabled}
|
||||
onClick={onClickPrevious}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : previousLink}
|
||||
>
|
||||
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
|
||||
{intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNextButton = () => {
|
||||
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
|
||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
|
||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||
const disabled = isLastUnit && !exitActive;
|
||||
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
|
||||
@@ -35,8 +54,10 @@ const UnitNavigation = ({
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="next-button d-flex align-items-center justify-content-center"
|
||||
onClick={buttonOnClick}
|
||||
onClick={onClickNext}
|
||||
disabled={disabled}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
|
||||
{buttonText}
|
||||
@@ -46,18 +67,9 @@ const UnitNavigation = ({
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
|
||||
return (
|
||||
<div className="unit-navigation d-flex">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="previous-button mr-2 d-flex align-items-center justify-content-center"
|
||||
disabled={isFirstUnit}
|
||||
onClick={onClickPrevious}
|
||||
>
|
||||
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
|
||||
{intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderPreviousButton()}
|
||||
{renderNextButton()}
|
||||
</div>
|
||||
);
|
||||
@@ -69,7 +81,6 @@ UnitNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
onClickPrevious: PropTypes.func.isRequired,
|
||||
onClickNext: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UnitNavigation.defaultProps = {
|
||||
|
||||
@@ -22,7 +22,6 @@ describe('Unit Navigation', () => {
|
||||
sequenceId: courseware.sequenceId,
|
||||
onClickPrevious: () => {},
|
||||
onClickNext: () => {},
|
||||
goToCourseExitPage: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,7 +35,7 @@ describe('Unit Navigation', () => {
|
||||
/>);
|
||||
|
||||
// Only "Previous" and "Next" buttons should be rendered.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles the clicks', () => {
|
||||
@@ -45,23 +44,21 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(<UnitNavigation
|
||||
{...mockData}
|
||||
sequenceId=""
|
||||
unitId=""
|
||||
onClickPrevious={onClickPrevious}
|
||||
onClickNext={onClickNext}
|
||||
/>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(onClickPrevious).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
expect(onClickNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
|
||||
render(<UnitNavigation {...mockData} />);
|
||||
|
||||
screen.getAllByRole('button').forEach(button => {
|
||||
screen.getAllByRole('link').forEach(button => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +67,7 @@ describe('Unit Navigation', () => {
|
||||
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
|
||||
|
||||
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 () => {
|
||||
@@ -85,7 +82,7 @@ describe('Unit Navigation', () => {
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -101,8 +98,8 @@ describe('Unit Navigation', () => {
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||
@@ -122,7 +119,7 @@ describe('Unit Navigation', () => {
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sequenceIdsSelector } from '../../../data';
|
||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const courseId = useSelector(state => state.courseware.courseId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
@@ -14,12 +15,43 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
||||
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
||||
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
|
||||
|
||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||
|
||||
const isFirstSequence = sequenceIndex === 0;
|
||||
const isFirstUnitInSequence = unitIndex === 0;
|
||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
|
||||
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
|
||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||
|
||||
return { isFirstUnit, isLastUnit };
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
|
||||
let nextLink;
|
||||
if (isLastUnit) {
|
||||
nextLink = `/course/${courseId}/course-end`;
|
||||
} else {
|
||||
const nextIndex = unitIndex + 1;
|
||||
if (nextIndex < sequence.unitIds.length) {
|
||||
const nextUnitId = sequence.unitIds[nextIndex];
|
||||
nextLink = `/course/${courseId}/${currentSequenceId}/${nextUnitId}`;
|
||||
} else if (nextSequenceId) {
|
||||
nextLink = `/course/${courseId}/${nextSequenceId}/first`;
|
||||
}
|
||||
}
|
||||
|
||||
let previousLink;
|
||||
const previousIndex = unitIndex - 1;
|
||||
if (previousIndex >= 0) {
|
||||
const previousUnitId = sequence.unitIds[previousIndex];
|
||||
previousLink = `/course/${courseId}/${currentSequenceId}/${previousUnitId}`;
|
||||
} else if (previousSequenceId) {
|
||||
previousLink = `/course/${courseId}/${previousSequenceId}/last`;
|
||||
}
|
||||
|
||||
return {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
import React from 'react';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
|
||||
const Sidebar = () => {
|
||||
const {
|
||||
currentSidebar,
|
||||
} = useContext(SidebarContext);
|
||||
if (!currentSidebar) {
|
||||
return null;
|
||||
}
|
||||
const CurrentSidebar = SIDEBARS[currentSidebar].Sidebar;
|
||||
return (
|
||||
<CurrentSidebar />
|
||||
);
|
||||
};
|
||||
const Sidebar = () => (
|
||||
<>
|
||||
{
|
||||
SIDEBAR_ORDER.map((sideBarId) => {
|
||||
const SidebarToRender = SIDEBARS[sideBarId].Sidebar;
|
||||
return <SidebarToRender />;
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -5,8 +5,6 @@ import React, {
|
||||
} from 'react';
|
||||
|
||||
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
||||
import { getSessionStorage } from '../../../data/sessionStorage';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
@@ -15,24 +13,18 @@ const SidebarProvider = ({
|
||||
unitId,
|
||||
children,
|
||||
}) => {
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
|
||||
const initialSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
|
||||
? SIDEBARS.NOTIFICATIONS.ID
|
||||
: null;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
|
||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
useEffect(() => {
|
||||
// As a one-off set initial sidebar if the verified mode data has just loaded
|
||||
if (verifiedMode && currentSidebar === null && initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialSidebar, verifiedMode]);
|
||||
}, [unitId]);
|
||||
|
||||
const onNotificationSeen = useCallback(() => {
|
||||
setNotificationStatus('inactive');
|
||||
|
||||
@@ -34,12 +34,14 @@ const SidebarBase = ({
|
||||
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
return currentSidebar === sidebarId && (
|
||||
return (
|
||||
<section
|
||||
className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top', {
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'min-vh-100': !shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
}, className)}
|
||||
data-testid={`sidebar-${sidebarId}`}
|
||||
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,8 @@ const DiscussionsSidebar = ({ intl }) => {
|
||||
src={`${discussionsUrl}?inContextSidebar`}
|
||||
className="d-flex w-100 h-100 border-0"
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
allow="clipboard-write"
|
||||
loading="lazy"
|
||||
/>
|
||||
</SidebarBase>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { QuestionAnswer } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
||||
@@ -23,12 +23,16 @@ const DiscussionsTrigger = ({
|
||||
courseId,
|
||||
} = useContext(SidebarContext);
|
||||
const dispatch = useDispatch();
|
||||
const { tabs } = useModel('courseHomeMeta', courseId);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
|
||||
const edxProvider = useMemo(
|
||||
() => tabs?.find(tab => tab.slug === 'discussion'),
|
||||
[tabs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch the topic data if the MFE is configured.
|
||||
if (baseUrl) {
|
||||
if (baseUrl && edxProvider) {
|
||||
dispatch(getCourseDiscussionTopics(courseId));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -57,4 +57,5 @@ Factory.define('courseMetadata')
|
||||
related_programs: null,
|
||||
user_needs_integrity_signature: false,
|
||||
recommendations: null,
|
||||
learning_assistant_enabled: null,
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ function normalizeMetadata(metadata) {
|
||||
relatedPrograms: camelCaseObject(data.related_programs),
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
canAccessProctoredExams: data.can_access_proctored_exams,
|
||||
learningAssistantEnabled: data.learning_assistant_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||
|
||||
import {
|
||||
getCourseMetadata,
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
|
||||
const {
|
||||
somethingLike: like, term, boolean, string, eachLike, integer,
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
|
||||
@@ -33,37 +33,13 @@ const provider = new Pact({
|
||||
describe('Courseware Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, 'Custom app config for pact tests');
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
|
||||
describe('When a request to get a learning sequence outline is made', () => {
|
||||
it('returns a normalized outline', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Outline exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: {
|
||||
sections: [],
|
||||
sequences: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedOutline = {
|
||||
courses: {
|
||||
'course-v1:edX+DemoX+Demo_Course': {
|
||||
@@ -76,74 +52,32 @@ describe('Courseware Service', () => {
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
const response = await getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Outline exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: {
|
||||
sections: [],
|
||||
sequences: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
it('skips unreleased sequences', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: like({
|
||||
sections: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially released',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
],
|
||||
effective_start: null,
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
|
||||
title: 'Wholly unreleased',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
],
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
],
|
||||
sequences: {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
title: 'Can access',
|
||||
accessible: true,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
title: 'Released and inaccessible',
|
||||
accessible: false,
|
||||
effective_start: '2019-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
title: 'Unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
title: 'Still unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedOutline = {
|
||||
courses: {
|
||||
'course-v1:edX+DemoX+Demo_Course': {
|
||||
@@ -179,120 +113,78 @@ describe('Courseware Service', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const response = await getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: like({
|
||||
sections: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially released',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
],
|
||||
effective_start: null,
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
|
||||
title: 'Wholly unreleased',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
],
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
],
|
||||
sequences: {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
title: 'Can access',
|
||||
accessible: true,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
title: 'Released and inaccessible',
|
||||
accessible: false,
|
||||
effective_start: '2019-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
title: 'Unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
title: 'Still unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getLearningSequencesOutline(courseId);
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get course metadata is made', () => {
|
||||
it('returns normalized course metadata', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `course metadata exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get course metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/course/${courseId}`,
|
||||
query: {
|
||||
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
access_expiration: {
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
masquerading_expired_course: boolean(false),
|
||||
upgrade_deadline: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
upgrade_url: string('link'),
|
||||
},
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
content_type_gating_enabled: boolean(false),
|
||||
end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment: {
|
||||
mode: term({
|
||||
generate: 'audit',
|
||||
matcher: '^(audit|verified)$',
|
||||
}),
|
||||
is_active: boolean(true),
|
||||
},
|
||||
enrollment_start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment_end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
license: string('all-rights-reserved'),
|
||||
name: like('Demonstration Course'),
|
||||
offer: {
|
||||
code: string('code'),
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
original_price: string('$99'),
|
||||
discounted_price: string('$99'),
|
||||
percentage: integer(50),
|
||||
upgrade_url: string('url'),
|
||||
},
|
||||
related_programs: null,
|
||||
short_description: like(''),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
user_timezone: null,
|
||||
verified_mode: like({
|
||||
access_expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
show_calculator: boolean(false),
|
||||
original_user_is_staff: boolean(true),
|
||||
is_staff: boolean(true),
|
||||
course_access: like({
|
||||
has_access: true,
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
}),
|
||||
notes: { enabled: boolean(false), visible: boolean(true) },
|
||||
marketing_url: null,
|
||||
user_has_passing_grade: boolean(false),
|
||||
course_exit_page_is_active: boolean(false),
|
||||
certificate_data: {
|
||||
cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
|
||||
},
|
||||
verify_identity_url: null,
|
||||
verification_status: string('none'),
|
||||
linkedin_add_to_profile_url: null,
|
||||
user_needs_integrity_signature: boolean(false),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('returns normalized course metadata', () => {
|
||||
const normalizedCourseMetadata = {
|
||||
accessExpiration: {
|
||||
expirationDate: '2013-02-05T05:00:00Z',
|
||||
@@ -336,57 +228,125 @@ describe('Courseware Service', () => {
|
||||
linkedinAddToProfileUrl: null,
|
||||
relatedPrograms: null,
|
||||
userNeedsIntegritySignature: false,
|
||||
learningAssistantEnabled: false,
|
||||
};
|
||||
const response = await getCourseMetadata(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedCourseMetadata);
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `course metadata exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get course metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/course/${courseId}`,
|
||||
query: {
|
||||
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
access_expiration: {
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
masquerading_expired_course: boolean(false),
|
||||
upgrade_deadline: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
upgrade_url: string('link'),
|
||||
},
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
content_type_gating_enabled: boolean(false),
|
||||
end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment: {
|
||||
mode: term({
|
||||
generate: 'audit',
|
||||
matcher: '^(audit|verified)$',
|
||||
}),
|
||||
is_active: boolean(true),
|
||||
},
|
||||
enrollment_start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
enrollment_end: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
license: string('all-rights-reserved'),
|
||||
name: like('Demonstration Course'),
|
||||
offer: {
|
||||
code: string('code'),
|
||||
expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
original_price: string('$99'),
|
||||
discounted_price: string('$99'),
|
||||
percentage: integer(50),
|
||||
upgrade_url: string('url'),
|
||||
},
|
||||
related_programs: null,
|
||||
short_description: like(''),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
user_timezone: null,
|
||||
verified_mode: like({
|
||||
access_expiration_date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
show_calculator: boolean(false),
|
||||
original_user_is_staff: boolean(true),
|
||||
is_staff: boolean(true),
|
||||
course_access: like({
|
||||
has_access: true,
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
}),
|
||||
notes: { enabled: boolean(false), visible: boolean(true) },
|
||||
marketing_url: null,
|
||||
user_has_passing_grade: boolean(false),
|
||||
course_exit_page_is_active: boolean(false),
|
||||
certificate_data: {
|
||||
cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
|
||||
},
|
||||
verify_identity_url: null,
|
||||
verification_status: string('none'),
|
||||
linkedin_add_to_profile_url: null,
|
||||
user_needs_integrity_signature: boolean(false),
|
||||
learning_assistant_enabled: boolean(false),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getCourseMetadata(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedCourseMetadata);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get sequence metadata is made', () => {
|
||||
it('returns normalized sequence metadata ', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
|
||||
uponReceiving: 'a request to get sequence metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/sequence/${sequenceId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
items: eachLike({
|
||||
content: '',
|
||||
page_title: 'Pointing on a Picture',
|
||||
type: 'problem',
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
bookmarked: false,
|
||||
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
|
||||
graded: true,
|
||||
contains_content_type_gated_content: false,
|
||||
href: '',
|
||||
}),
|
||||
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
|
||||
is_time_limited: boolean(false),
|
||||
is_proctored: boolean(false),
|
||||
is_hidden_after_due: boolean(false),
|
||||
position: null,
|
||||
tag: boolean('sequential'),
|
||||
banner_text: null,
|
||||
save_position: boolean(false),
|
||||
show_completion: boolean(false),
|
||||
gated_content: like({
|
||||
prereq_id: null,
|
||||
prereq_url: null,
|
||||
prereq_section_name: null,
|
||||
gated: false,
|
||||
gated_section_name: 'Homework - Question Styles',
|
||||
}),
|
||||
display_name: boolean('Homework - Question Styles'),
|
||||
format: boolean('Homework'),
|
||||
},
|
||||
},
|
||||
});
|
||||
it('returns normalized sequence metadata ', () => {
|
||||
const normalizedSequenceMetadata = {
|
||||
sequence: {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||
@@ -423,102 +383,154 @@ describe('Courseware Service', () => {
|
||||
containsContentTypeGatedContent: false,
|
||||
}],
|
||||
};
|
||||
const response = await getSequenceMetadata(sequenceId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedSequenceMetadata);
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
|
||||
uponReceiving: 'a request to get sequence metadata',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/sequence/${sequenceId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
items: eachLike({
|
||||
content: '',
|
||||
page_title: 'Pointing on a Picture',
|
||||
type: 'problem',
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
bookmarked: false,
|
||||
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
|
||||
graded: true,
|
||||
contains_content_type_gated_content: false,
|
||||
href: '',
|
||||
}),
|
||||
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
|
||||
is_time_limited: boolean(false),
|
||||
is_proctored: boolean(false),
|
||||
is_hidden_after_due: boolean(false),
|
||||
position: null,
|
||||
tag: boolean('sequential'),
|
||||
banner_text: null,
|
||||
save_position: boolean(false),
|
||||
show_completion: boolean(false),
|
||||
gated_content: like({
|
||||
prereq_id: null,
|
||||
prereq_url: null,
|
||||
prereq_section_name: null,
|
||||
gated: false,
|
||||
gated_section_name: 'Homework - Question Styles',
|
||||
}),
|
||||
display_name: boolean('Homework - Question Styles'),
|
||||
format: boolean('Homework'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getSequenceMetadata(sequenceId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedSequenceMetadata);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to set sequence position against Unit Index is made', () => {
|
||||
it('returns if the request was success or failure', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
||||
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
|
||||
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
success: boolean(true),
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
||||
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
|
||||
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await postSequencePosition(courseId, sequenceId, 0);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual({ success: true });
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
success: boolean(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', () => {
|
||||
it('returns the completion status', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
||||
uponReceiving: 'a request to get completion block',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
||||
body: { usage_key: usageId },
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
complete: boolean(true),
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
||||
uponReceiving: 'a request to get completion block',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
||||
body: { usage_key: usageId },
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await getBlockCompletion(courseId, sequenceId, usageId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(true);
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
complete: boolean(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', () => {
|
||||
it('returns block id, section id and unit id of the resume block', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Resume block exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get Resume block',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/resume/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
block_id: string('642fadf46d074aabb637f20af320fb31'),
|
||||
section_id: string('642fadf46d074aabb637f20af320fb87'),
|
||||
unit_id: string('642fadf46d074aabb637f20af320fb99'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
blockId: '642fadf46d074aabb637f20af320fb31',
|
||||
sectionId: '642fadf46d074aabb637f20af320fb87',
|
||||
unitId: '642fadf46d074aabb637f20af320fb99',
|
||||
};
|
||||
const response = await getResumeBlock(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Resume block exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get Resume block',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/courseware/resume/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
block_id: string('642fadf46d074aabb637f20af320fb31'),
|
||||
section_id: string('642fadf46d074aabb637f20af320fb87'),
|
||||
unit_id: string('642fadf46d074aabb637f20af320fb99'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = getResumeBlock(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to send activation email is made', () => {
|
||||
it('returns status code 200', async () => {
|
||||
await provider.addInteraction({
|
||||
state: 'A logged-in user may or may not be active',
|
||||
uponReceiving: 'a request to send activation email',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: '/api/send_account_activation_email',
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const response = await sendActivationEmail();
|
||||
expect(response).toEqual('');
|
||||
it('returns status code 200', () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: 'A logged-in user may or may not be active',
|
||||
uponReceiving: 'a request to send activation email',
|
||||
withRequest: {
|
||||
method: 'POST',
|
||||
path: '/api/send_account_activation_email',
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const response = sendActivationEmail();
|
||||
expect(response).toEqual('');
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
16
src/decode-page-route/__snapshots__/index.test.jsx.snap
Normal file
16
src/decode-page-route/__snapshots__/index.test.jsx.snap
Normal file
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
||||
<div>
|
||||
PageRoute: {
|
||||
"computedMatch": {
|
||||
"path": "/course/:courseId/home",
|
||||
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
|
||||
"isExact": true,
|
||||
"params": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course"
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
49
src/decode-page-route/index.jsx
Normal file
49
src/decode-page-route/index.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
import { useHistory, generatePath } from 'react-router';
|
||||
|
||||
export const decodeUrl = (encodedUrl) => {
|
||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||
if (encodedUrl === decodedUrl) {
|
||||
return encodedUrl;
|
||||
}
|
||||
return decodeUrl(decodedUrl);
|
||||
};
|
||||
|
||||
const DecodePageRoute = (props) => {
|
||||
const history = useHistory();
|
||||
if (props.computedMatch) {
|
||||
const { url, path, params } = props.computedMatch;
|
||||
|
||||
Object.keys(params).forEach((param) => {
|
||||
// only decode params not the entire url.
|
||||
// it is just to be safe and less prone to errors
|
||||
params[param] = decodeUrl(params[param]);
|
||||
});
|
||||
|
||||
const newUrl = generatePath(path, params);
|
||||
|
||||
// if the url get decoded, reroute to the decoded url
|
||||
if (newUrl !== url) {
|
||||
history.replace(newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return <PageRoute {...props} />;
|
||||
};
|
||||
|
||||
DecodePageRoute.propTypes = {
|
||||
computedMatch: PropTypes.shape({
|
||||
url: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
params: PropTypes.any,
|
||||
}),
|
||||
};
|
||||
|
||||
DecodePageRoute.defaultProps = {
|
||||
computedMatch: null,
|
||||
};
|
||||
|
||||
export default DecodePageRoute;
|
||||
103
src/decode-page-route/index.test.jsx
Normal file
103
src/decode-page-route/index.test.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router, matchPath } from 'react-router';
|
||||
import DecodePageRoute, { decodeUrl } from '.';
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const encodedCourseId = encodeURIComponent(decodedCourseId);
|
||||
const deepEncodedCourseId = (() => {
|
||||
let path = encodedCourseId;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
path = encodeURIComponent(path);
|
||||
}
|
||||
return path;
|
||||
})();
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
|
||||
}));
|
||||
|
||||
const renderPage = (props) => {
|
||||
const memHistory = createMemoryHistory({
|
||||
initialEntries: [props?.path],
|
||||
});
|
||||
|
||||
const history = {
|
||||
...memHistory,
|
||||
replace: jest.fn(),
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<DecodePageRoute computedMatch={props} />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
return {
|
||||
container,
|
||||
history,
|
||||
props,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DecodePageRoute', () => {
|
||||
it('should not modify the url if it does not need to be decoded', () => {
|
||||
const props = matchPath(`/course/${decodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { container, history } = renderPage(props);
|
||||
|
||||
expect(props.url).toContain(decodedCourseId);
|
||||
expect(history.replace).not.toHaveBeenCalled();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should decode the url and replace the history if necessary', () => {
|
||||
const props = matchPath(`/course/${encodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(encodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
});
|
||||
|
||||
it('should decode the url multiple times if necessary', () => {
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(deepEncodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
});
|
||||
|
||||
it('should only decode the url params and not the entire url', () => {
|
||||
const decodedUnitId = 'some+thing';
|
||||
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
|
||||
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
|
||||
const decodedUrls = history.replace.mock.calls[0][0].split('/');
|
||||
|
||||
// unitId get decoded
|
||||
expect(decodedUrls.pop()).toContain(decodedUnitId);
|
||||
|
||||
// path remain encoded
|
||||
expect(decodedUrls.pop()).toContain(encodedUnitId);
|
||||
|
||||
// courseId get decoded
|
||||
expect(decodedUrls.pop()).toContain(decodedCourseId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeUrl', () => {
|
||||
expect(decodeUrl(decodedCourseId)).toEqual(decodedCourseId);
|
||||
expect(decodeUrl(encodedCourseId)).toEqual(decodedCourseId);
|
||||
expect(decodeUrl(deepEncodedCourseId)).toEqual(decodedCourseId);
|
||||
});
|
||||
@@ -10,16 +10,19 @@ import { getNotices } from './api';
|
||||
*/
|
||||
const NoticesProvider = ({ children }) => {
|
||||
const [isRedirected, setIsRedirected] = useState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(async () => {
|
||||
if (getConfig().ENABLE_NOTICES) {
|
||||
const data = await getNotices();
|
||||
if (data && data.results && data.results.length > 0) {
|
||||
const { results } = data;
|
||||
setIsRedirected(true);
|
||||
window.location.replace(`${results[0]}?next=${window.location.href}`);
|
||||
|
||||
useEffect(() => {
|
||||
async function getData() {
|
||||
if (getConfig().ENABLE_NOTICES) {
|
||||
const data = await getNotices();
|
||||
if (data && data.results && data.results.length > 0) {
|
||||
const { results } = data;
|
||||
setIsRedirected(true);
|
||||
window.location.replace(`${results[0]}?next=${window.location.href}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
getData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 frMessages from './messages/fr.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 ruMessages from './messages/ru.json';
|
||||
import hiMessages from './messages/hi.json';
|
||||
import faIRMessages from './messages/fa_IR.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
|
||||
|
||||
const messages = {
|
||||
const appMessages = {
|
||||
ar: arMessages,
|
||||
'es-419': es419Messages,
|
||||
fr: frMessages,
|
||||
@@ -20,9 +28,18 @@ const messages = {
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
hi: hiMessages,
|
||||
'fa-ir': faIRMessages,
|
||||
'fr-ca': frCAMessages,
|
||||
ru: ruMessages,
|
||||
uk: ukMessages,
|
||||
'de-de': dedeCAMessages,
|
||||
'it-it': ititCAMessages,
|
||||
'pt-pt': ptptCAMessages,
|
||||
};
|
||||
|
||||
export default messages;
|
||||
export default [
|
||||
paragonMessages,
|
||||
appMessages,
|
||||
footerMessages,
|
||||
headerMessages,
|
||||
];
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "يفتح الامتحان التحضيري في: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "مراجعة التعليمات و متطلبات النظام",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "انقضى أجَل الامتحان التحضيري",
|
||||
"learning.outline.sequence-due": "{description} للتسليم قبل {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "لتوليد شهادة، يجب عليك إكمال التحقق من هويتك. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "اعرض إنجازاتك اليوم على لينكد إن أو ضمن سيرتك الذاتية. يمكنك تحميل شهادتك الآن و الوصول إليها في أي وقت من لوحة معلوماتك و ملفك الشخصي.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "تمت جدولة ظهور الدرجات النهائية و أي شهادات مكتسبة ابتداءً من {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
452
src/i18n/messages/de_DE.json
Normal file
452
src/i18n/messages/de_DE.json
Normal file
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"learning.accessExpiration.deadline": "Upgraden Sie bis {date}, um unbegrenzten Zugang zu diesem Kurs zu erhalten, solange dieser auf dieser Seite existiert.",
|
||||
"learning.accessExpiration.header": "Audit-Zugriff gültig bis {date}",
|
||||
"learning.accessExpiration.body": "Sie verlieren am {date} jeglichen Zugriff auf diesen Kurs, einschließlich Ihres Fortschritts.",
|
||||
"instructorToolbar.pageBanner.courseHasExpired": "Dieser Teilnehmer hat keinen Zugriff mehr auf diesen Kurs. Der Zugriff ist am {date} abgelaufen.",
|
||||
"learning.accessExpiration.upgradeNow": "Jetzt aktualisieren",
|
||||
"learning.activeEnterprise.alert": "{changeActiveEnterprise}.",
|
||||
"learning.activeEnterprise.change.alert": "Jetzt Unternehmen wechseln",
|
||||
"learning.outline.alert.start.short": "Der Kurs beginnt {timeRemaining} um {courseStartTime}.",
|
||||
"learning.outline.alert.end.long": "Dieser Kurs endet am {courseEndDate} {timeRemaining}.",
|
||||
"learning.outline.alert.end.calendar": "Vergessen Sie nicht, eine Kalendererinnerung einzurichten!",
|
||||
"instructorToolbar.pageBanner.courseHasNotStarted": "Dieser Teilnehmer hat noch keinen Zugriff auf diesen Kurs. Der Kurs beginnt am {date}.",
|
||||
"learning.enrollment.alert": "Sie müssen im Kurs eingeschrieben sein, um den Inhalt sehen zu können.",
|
||||
"learning.staff.enrollment.alert": "Sie betrachten diesen Kurs als Dozent ohne eingeschrieben zu sein.",
|
||||
"learning.enrollment.enrollNow.Inline": "Jetzt einschreiben",
|
||||
"learning.enrollment.enrollNow.Sentence": "Jetzt einschreiben",
|
||||
"learning.enrollment.success": "Sie haben sich erfolgreich für diesen Kurs angemeldet!",
|
||||
"account-activation.alert.button": "Weiter zu {siteName}",
|
||||
"account-activation.alert.message": "Wir haben eine E-Mail an {boldEmail} mit einem Link zur Aktivierung Ihres Kontos gesendet. Ist die E-Mail nicht angekommen? Überprüfen Sie Ihren Spam-Ordner oder {sendEmailTag}.",
|
||||
"account-activation.resend.link": "E-Mail erneut senden",
|
||||
"learning.logistration.alert": "Um den Inhalt des Kurses sehen zu können, müssen Sie sich erst {sign_in_link} oder {register_link}.",
|
||||
"account-activation.alert.title": "Aktivieren Sie Ihr Konto um sich wieder anmelden zu können",
|
||||
"learn.sequence.entranceExamTextNotPassing": "Um auf Kursmaterialien zugreifen zu können, müssen Sie bei dieser Prüfung mindestens {entranceExamMinimumScorePct} % erreichen. Ihre aktuelle Punktzahl beträgt {entranceExamCurrentScore} %.",
|
||||
"learn.sequence.entranceExamTextPassed": "Ihre Punktzahl beträgt {entranceExamCurrentScore} %, damit haben Sie die Aufnahmeprüfung bestanden.",
|
||||
"learning.dates.badge.completed": "Abgeschlossen",
|
||||
"learning.dates.badge.dueNext": "Nächster Abgabetermin",
|
||||
"learning.dates.badge.pastDue": "Überfällig",
|
||||
"learning.dates.title": "Wichtige Daten",
|
||||
"learning.dates.badge.today": "Heute",
|
||||
"learning.dates.badge.unreleased": "Noch nicht veröffentlicht",
|
||||
"learning.dates.badge.verifiedOnly": "Nur verifiziert",
|
||||
"learning.goals.unsubscribe.contact": "kontaktieren Sie den Support",
|
||||
"learning.goals.unsubscribe.description": "Sie erhalten keine E-Mail-Erinnerungen mehr für {courseTitle}.",
|
||||
"learning.goals.unsubscribe.errorHeader": "Es ist ein Fehler aufgetreten",
|
||||
"learning.goals.unsubscribe.goToDashboard": "Zur Übersicht",
|
||||
"learning.goals.unsubscribe.header": "Sie haben sich von der E-Mail-Liste für die Kurserinnerungen abgemeldet",
|
||||
"learning.goals.unsubscribe.loading": "Abmelden…",
|
||||
"learning.goals.unsubscribe.errorDescription": "Wir konnten Sie nicht von Kurserinnerungs-E-Mails abmelden. Bitte versuchen Sie es später erneut oder {contactSupport} um Hilfe.",
|
||||
"learning.outline.alert.cert.earnedNotAvailable": "Dieser Kurs endet am {courseEndDateFormatted}. Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {certificateAvailableDate} verfügbar.",
|
||||
"cert.alert.earned.unavailable.header.v2": "Ihre Note und Ihr Zertifikat werden in Kürze verfügbar sein.",
|
||||
"cert.alert.earned.ready.header": "Herzliche Glückwünsche! Ihr Zertifikat ist fertig.",
|
||||
"cert.alert.notPassing.header": "Sie haben noch keinen Anspruch auf ein Zertifikat",
|
||||
"cert.alert.notPassing.button": "Noten ansehen",
|
||||
"learning.outline.alert.end.short": "Dieser Kurs endet in {timeRemaining} um {courseEndTime}.",
|
||||
"alert.enroll": "um auf den vollständigen Kurs zuzugreifen.",
|
||||
"learning.privateCourse.signInOrRegister": "{signIn} oder {register} und schreiben Sie sich dann für diesen Kurs ein.",
|
||||
"learning.outline.alert.scheduled-content.heading": "Weitere Inhalte folgen in Kürze!",
|
||||
"learning.outline.alert.scheduled-content.body": "Für diesen Kurs werden zu einem späteren Zeitpunkt weitere Inhalte veröffentlicht. Achten Sie auf E-Mail-Updates oder schauen Sie in diesem Kurs für Updates.",
|
||||
"learning.outline.alert.scheduled-content.button": "Kursplan ansehen",
|
||||
"learning.outline.dates.all": "Alle Kurstermine anzeigen",
|
||||
"learning.outline.goalButton.casual.text": "1 Tag pro Woche",
|
||||
"learning.outline.goalButton.screenReader.text": "Lässig",
|
||||
"learning.outline.certificateAlt": "Beispielzertifikat",
|
||||
"learning.outline.collapseAll": "Alles zusammenklappen",
|
||||
"learning.outline.completedAssignment": "Beendet",
|
||||
"learning.outline.completedSection": "Abgeschlossener Abschnitt",
|
||||
"learning.outline.dates": "Wichtige Daten",
|
||||
"learning.outline.editGoal": "Lernziel bearbeiten",
|
||||
"learning.outline.expandAll": "Alle erweitern",
|
||||
"learning.outline.goal": "Lernziel",
|
||||
"learning.outline.goalReminderDetail": "Wenn wir feststellen, dass Sie Ihr Lernziel nicht erreicht haben, senden wir Ihnen eine E-Mail-Erinnerung.",
|
||||
"learning.outline.goalUnsure": "Noch nicht sicher",
|
||||
"learning.outline.handouts": "Kursmaterialien",
|
||||
"learning.outline.incompleteAssignment": "Unvollständig",
|
||||
"learning.outline.incompleteSection": "Unvollständiger Abschnitt",
|
||||
"learning.outline.goalButton.intense.text": "5 Tage die Woche",
|
||||
"learning.outline.goalButton.intense.title": "Intensiv",
|
||||
"learning.outline.learnMore": "Mehr erfahren",
|
||||
"learning.outline.altText.openSection": "Öffnen",
|
||||
"learning.proctoringPanel.header": "Dieser Kurs enthält beaufsichtigte Prüfungen",
|
||||
"learning.outline.goalButton.regular.text": "3 Tage die Woche",
|
||||
"learning.outline.goalButton.regular.title": "Regulär",
|
||||
"learning.outline.resumeBlurb": "Machen Sie dort weiter, wo Sie aufgehört haben",
|
||||
"learning.outline.resume": "Kurs fortsetzen",
|
||||
"learning.outline.setGoal": "Legen Sie zunächst ein Lernziel fest, indem Sie unten die Option auswählen, die Ihren Lernplan am besten beschreibt.",
|
||||
"learning.outline.setGoalReminder": "Definieren Sie Ihre Lernzielerinnerungen",
|
||||
"learning.outline.goalButton.casual.title": "Definieren Sie einen Lernzielstil.",
|
||||
"learning.outline.setWeeklyGoal": "Setzen Sie sich ein wöchentliches Lernziel",
|
||||
"learning.outline.setWeeklyGoalDetail": "Das Setzen eines Lernziels motiviert Sie, den Kurs zu beenden. Sie können es jederzeit anpassen.",
|
||||
"learning.outline.start": "Kurs starten",
|
||||
"learning.outline.startBlurb": "Beginnen Sie noch heute Ihren Kurs",
|
||||
"learning.outline.tools": "Kurswerkzeuge",
|
||||
"learning.outline.upgradeButton": "Upgrade ({symbol}{price})",
|
||||
"learning.outline.upgradeTitle": "Erhalten Sie ein Zertifikat",
|
||||
"learning.outline.welcomeMessage": "Willkommensnachricht",
|
||||
"learning.outline.welcomeMessageShowMoreButton": "Mehr anzeigen",
|
||||
"learning.outline.welcomeMessageShowLessButton": "Weniger anzeigen",
|
||||
"learning.outline.goalWelcome": "Willkommen zu",
|
||||
"learning.proctoringPanel.status.notStarted": "Nicht begonnen",
|
||||
"learning.proctoringPanel.status.started": "Gestartet",
|
||||
"learning.proctoringPanel.status.submitted": "Abgesendet",
|
||||
"learning.proctoringPanel.status.verified": "Geprüft",
|
||||
"learning.proctoringPanel.status.rejected": "Zurückgewiesen",
|
||||
"learning.proctoringPanel.status.error": "Fehler",
|
||||
"learning.proctoringPanel.status.otherCourseApproved": "In einem anderen Studiengang genehmigt",
|
||||
"learning.proctoringPanel.status.expiringSoon": "Läuft bald ab",
|
||||
"learning.proctoringPanel.status.expired": "Abgelaufen",
|
||||
"learning.proctoringPanel.status": "Aktueller Onboarding-Status:",
|
||||
"learning.proctoringPanel.message.notStarted": "Sie haben Ihre Onboarding-Prüfung noch nicht begonnen.",
|
||||
"learning.proctoringPanel.message.started": "Sie haben Ihre Onboarding-Prüfung begonnen.",
|
||||
"learning.proctoringPanel.message.submitted": "Sie haben Ihre Onboarding-Prüfung eingereicht.",
|
||||
"learning.proctoringPanel.message.verified": "Ihre Onboarding-Prüfung wurde in diesem Kurs genehmigt.",
|
||||
"learning.proctoringPanel.message.rejected": "Ihre Onboarding-Prüfung wurde abgelehnt. Bitte versuchen Sie das Onboarding erneut.",
|
||||
"learning.proctoringPanel.message.error": "Während Ihrer Onboarding-Prüfung ist ein Fehler aufgetreten. Bitte versuchen Sie das Onboarding erneut.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "Ihre Onboarding-Prüfung wurde in einem anderen Kurs genehmigt.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "Wenn sich Ihr Gerät geändert hat, empfehlen wir Ihnen, die Onboarding-Prüfung dieses Kurses zu absolvieren, um sicherzustellen, dass Ihre Einrichtung weiterhin die Anforderungen für die Aufsicht erfüllt.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Ihr Onboarding-Profil wurde genehmigt. Ihr Onboarding-Status läuft jedoch bald ab. Bitte führen Sie das Onboarding erneut durch, um sicherzustellen, dass Sie weiterhin beaufsichtigte Prüfungen ablegen können.",
|
||||
"learning.proctoringPanel.message.expired": "Ihr Onboarding-Status ist abgelaufen. Bitte schließen Sie das Onboarding erneut ab, um weiterhin beaufsichtigte Prüfungen ablegen zu können.",
|
||||
"learning.proctoringPanel.generalInfo": "Sie müssen den Onboarding-Prozess abschließen, bevor Sie eine beaufsichtigte Prüfung ablegen.",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "Ihr eingereichtes Profil wird überprüft.",
|
||||
"learning.proctoringPanel.generalTime": "Die Überprüfung des Onboarding-Profils kann mehr als 2 Werktage dauern.",
|
||||
"learning.proctoringPanel.onboardingButton": "Onboarding vervollständigt",
|
||||
"learning.proctoringPanel.onboardingPracticeButton": "Onboarding-Prüfung anzeigen",
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding-Öffnungen: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Lesen Sie die Anweisungen und Systemanforderungen",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding überfällig",
|
||||
"learning.outline.sequence-due-date-set": "{description} fällig {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "Um ein Zertifikat zu generieren, müssen Sie die ID-Verifizierung abschließen. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Präsentieren Sie Ihre Leistung noch heute auf LinkedIn oder in Ihrem Lebenslauf. Sie können Ihr Zertifikat jetzt herunterladen und jederzeit über Ihr Dashboard und Ihr Profil darauf zugreifen.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {endDate} verfügbar.",
|
||||
"progress.certificateStatus.notPassingHeader": "Zertifikatsstatus",
|
||||
"progress.certificateStatus.notPassingBody": "Um sich für ein Zertifikat zu qualifizieren, müssen Sie eine bestandene Note haben.",
|
||||
"progress.certificateStatus.inProgressHeader": "Weitere Inhalte folgen in Kürze!",
|
||||
"progress.certificateStatus.inProgressBody": "Es sieht so aus, als gäbe es in diesem Kurs weitere Inhalte, die in Zukunft veröffentlicht werden. Achten Sie auf E-Mail-Updates oder sehen Sie in Ihrem Kurs nach, wann diese Inhalte verfügbar sein werden.",
|
||||
"progress.certificateStatus.requestableHeader": "Zertifikatsstatus",
|
||||
"progress.certificateStatus.requestableBody": "Herzlichen Glückwunsch, Sie haben sich für ein Zertifikat qualifiziert! Um auf Ihr Zertifikat zuzugreifen, fordern Sie es unten an.",
|
||||
"progress.certificateStatus.requestableButton": "Zertifikat anfordern",
|
||||
"progress.certificateStatus.unverifiedHeader": "Zertifikatsstatus",
|
||||
"progress.certificateStatus.unverifiedButton": "ID verifizieren",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "Ihre ID-Überprüfung steht noch aus und Ihr Zertifikat ist verfügbar, sobald es genehmigt wurde.",
|
||||
"progress.certificateStatus.downloadableHeader": "Ihr Zertifikat liegt vor!",
|
||||
"progress.certificateStatus.viewableButton": "Sehen Sie sich mein Zertifikat an",
|
||||
"progress.certificateStatus.notAvailableHeader": "Zertifikatsstatus",
|
||||
"progress.certificateBody.notAvailable.endDate": "Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {endDate} verfügbar.",
|
||||
"progress.certificateStatus.upgradeHeader": "Erhalten Sie ein Zertifikat",
|
||||
"progress.certificateStatus.upgradeBody": "Sie befinden sich in einem Audit Track und qualifizieren sich nicht für ein Zertifikat. Um auf ein Zertifikat hinzuarbeiten, upgraden Sie noch heute Ihren Kurs.",
|
||||
"progress.certificateStatus.upgradeButton": "Jetzt aktualisieren",
|
||||
"progress.certificateStatus.unverifiedHomeHeader.v2": "Überprüfen Sie Ihre Identität, um sich für ein Zertifikat zu qualifizieren.",
|
||||
"progress.certificateStatus.unverifiedHomeButton": "Meine ID bestätigen",
|
||||
"progress.certificateStatus.unverifiedHomeBody": "Um ein Zertifikat für diesen Kurs zu generieren, müssen Sie den ID-Verifizierungsprozess abschließen.",
|
||||
"progress.completion.donut.label": "abgeschlossen",
|
||||
"progress.completion.body": "Dies stellt dar, wie viel der Kursinhalte Sie abgeschlossen haben. Beachten Sie, dass einige Inhalte möglicherweise noch nicht veröffentlicht wurden.",
|
||||
"progress.completion.tooltip.locked": "Inhalte, die Sie abgeschlossen haben.",
|
||||
"progress.completion.header": "Kursabschluss",
|
||||
"progress.completion.tooltip": "Inhalte, auf die Sie Zugriff haben und die Sie noch nicht abgeschlossen haben.",
|
||||
"progress.completion.tooltip.complete": "Inhalt, der gesperrt und nur für diejenigen verfügbar ist, die ein Upgrade durchführen.",
|
||||
"progress.completion.donut.percentComplete": "Sie haben {percent} % des Inhalts dieses Kurses abgeschlossen.",
|
||||
"progress.completion.donut.percentIncomplete": "Sie haben {percent} % der Inhalte in diesem Kurs, auf die Sie Zugriff haben, noch nicht abgeschlossen.",
|
||||
"progress.completion.donut.percentLocked": "{percent} % der Inhalte in diesem Kurs sind gesperrt und nur für diejenigen verfügbar, die ein Upgrade durchführen.",
|
||||
"progress.creditInformation.creditNotEligible": "Sie können diesen Kurs nicht mehr anrechnen. Erfahren Sie mehr über {creditLink}.",
|
||||
"progress.creditInformation.creditEligible": "\nSie haben die Voraussetzungen für die Anrechnung in diesem Kurs erfüllt. Gehen Sie zu Ihrem\n {dashboardLink}, um Kursguthaben zu erwerben. Oder erfahren Sie mehr über {creditLink}.",
|
||||
"progress.creditInformation.creditPartialEligible": "Sie haben die Kreditvoraussetzungen noch nicht erfüllt. Erfahren Sie mehr über {creditLink}.",
|
||||
"progress.creditInformation.completed": "Beendet",
|
||||
"progress.creditInformation.courseCredit": "Kurskredit",
|
||||
"progress.creditInformation.minimumGrade": "Mindestnote für Kreditpunkte ({minGrade}%)",
|
||||
"progress.creditInformation.requirementsHeader": "Voraussetzungen für den Studienkredit",
|
||||
"progress.creditInformation.upcoming": "Demnächst",
|
||||
"progress.creditInformation.verificationFailed": "Überprüfung fehlgeschlagen",
|
||||
"progress.creditInformation.verificationSubmitted": "Bestätigung eingereicht",
|
||||
"progress.ungradedAlert": "Informationen zum Fortschritt bei nicht benoteten Aspekten des Kurses finden Sie in Ihrem {outlineLink}.",
|
||||
"progress.footnotes.droppableAssignments": "Die niedrigsten {numDroppable, plural, one{# {assignmentType} Punktzahl ist} other{# {assignmentType} Punktzahlen werden}} fallen gelassen.",
|
||||
"progress.assignmentType": "Auftragsart",
|
||||
"progress.footnotes.backToContent": "Zurück zum Inhalt",
|
||||
"progress.courseGrade.body": "Dies stellt Ihre gewichtete Note gegenüber der Note dar, die zum Bestehen dieses Kurses erforderlich ist.",
|
||||
"progress.courseGrade.gradeBar.altText": "Ihre aktuelle Note ist {currentGrade} %. Zum Bestehen dieses Kurses ist eine gewichtete Note von {passingGrade} % erforderlich.",
|
||||
"progress.courseGrade.footer.generic.passing": "Sie absolvieren derzeit diesen Kurs",
|
||||
"progress.courseGrade.footer.nonPassing": "Zum Bestehen dieses Kurses ist eine gewichtete Note von {passingGrade} % erforderlich",
|
||||
"progress.courseGrade.footer.passing": "Sie bestehen diesen Kurs derzeit mit der Note {letterGrade} ({minGrade}-{maxGrade}%)",
|
||||
"progress.courseGrade.preview.headerLocked": "gesperrte Funktion",
|
||||
"progress.courseGrade.preview.headerLimited": "eingeschränkte Funktion",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "Vorschau auf a",
|
||||
"progress.courseGrade.preview.body.unlockCertificate": "Entsperren, um Noten anzuzeigen und auf ein Zertifikat hinzuarbeiten.",
|
||||
"progress.courseGrade.partialpreview.body.unlockCertificate": "Entsperren, um auf ein Zertifikat hinzuarbeiten.",
|
||||
"progress.courseGrade.preview.body.upgradeDeadlinePassed": "Die Frist für das Upgrade in diesem Kurs ist abgelaufen.",
|
||||
"progress.courseGrade.preview.button.upgrade": "Jetzt aktualisieren",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Notenbereiche für diesen Kurs:",
|
||||
"progress.courseOutline": "Kursübersicht",
|
||||
"progress.courseGrade.label.currentGrade": "Ihre aktuelle Note",
|
||||
"progress.detailedGrades": "Detaillierte Noten",
|
||||
"progress.detailedGrades.emptyTable": "Sie haben derzeit keine benoteten Problemergebnisse.",
|
||||
"progress.footnotes.title": "Notenzusammenfassung Fußnoten",
|
||||
"progress.gradeSummary.grade": "Note",
|
||||
"progress.courseGrade.grades": "Noten",
|
||||
"progress.courseGrade.gradesAndCredit": "Noten & Kredit",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Kurzinfo zum Notenbereich",
|
||||
"progress.gradeSummary": "Zusammenfassung der Noten",
|
||||
"progress.gradeSummary.limitedAccessExplanation": "Sie haben eingeschränkten Zugriff auf benotete Aufgaben im Rahmen des Audit-Tracks in diesem Kurs.",
|
||||
"progress.gradeSummary.tooltip.alt": "Kurzinfo zur Notenzusammenfassung",
|
||||
"progress.gradeSummary.tooltip.body": "Das Gewicht Ihrer Kursaufgabe wird von Ihrem Kursleiter festgelegt. Indem Sie Ihre Note mit der Gewichtung für diesen Aufgabentyp multiplizieren, wird Ihre gewichtete Note berechnet. Ihre gewichtete Note wird verwendet, um festzustellen, ob Sie den Kurs bestehen.",
|
||||
"progress.noAcessToAssignmentType": "Sie haben keinen Zugriff auf Aufgaben des Typs {assignmentType}",
|
||||
"progress.noAcessToSubsection": "Sie haben keinen Zugriff auf den Unterabschnitt {displayName}",
|
||||
"progress.courseGrade.label.passingGrade": "Klasse bestehen",
|
||||
"progress.detailedGrades.problemScore.label": "Problemwerte:",
|
||||
"progress.detailedGrades.problemScore.toggleButton": "Einzelne Problembewertungen für {subsectionTitle} umschalten",
|
||||
"progress.detailedGrades.overridden": "Die Note für den Abschnitt wurde überschrieben.",
|
||||
"progress.score": "Punkte",
|
||||
"progress.weight": "Gewichtung",
|
||||
"progress.weightedGrade": "Gewichtete Note",
|
||||
"progress.weightedGradeSummary": "Ihre aktuelle gewichtete Notenzusammenfassung",
|
||||
"progress.header": "Dein Fortschritt",
|
||||
"progress.header.targetUser": "Kursfortschritt für {username}",
|
||||
"progress.link.studio": "Grading in Studio anzeigen",
|
||||
"progress.relatedLinks.datesCard.description": "Eine Zeitplanansicht Ihrer Kurstermine und anstehenden Aufgaben.",
|
||||
"progress.relatedLinks.datesCard.link": "Daten",
|
||||
"progress.relatedLinks.outlineCard.description": "Ihre Kursinhalte aus der Vogelperspektive.",
|
||||
"progress.relatedLinks.outlineCard.link": "Kursübersicht",
|
||||
"progress.relatedLinks": "Ähnliche Links",
|
||||
"datesBanner.suggestedSchedule": "Wir haben einen vorgeschlagenen Zeitplan erstellt, um Ihnen zu helfen, auf dem richtigen Weg zu bleiben. Aber keine Sorge – es ist flexibel, sodass Sie in Ihrem eigenen Tempo lernen können.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.header": "Upgrade, um dies freizuschalten.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.body": "Sie sind Gasthörer für diesen Kurs, was bedeutet, dass Sie nicht an benoteten Aufgaben teilnehmen können. Um benotete Aufgaben im Rahmen dieses Kurses abzuschließen, können Sie noch heute upgraden.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.button": "Jetzt aktualisieren",
|
||||
"datesBanner.upgradeToResetBanner.body": "Sie können den Ablaufplan individuell anpassen und die überfälligen Aufgaben auf einen späteren Zeitpunkt verschieben. Keine Sorge—Ihr bisheriger Fortschritt geht dabei nicht verloren.",
|
||||
"datesBanner.upgradeToResetBanner.button": "Jetzt aktualisieren, um Abgabetermine zu verschieben.",
|
||||
"datesBanner.resetDatesBanner.header": "Es scheint, als wenn Sie ein paar wichtige Fristen unseres vorgeschlagenen Ablaufplans verpasst.",
|
||||
"datesBanner.resetDatesBanner.body": "Sie können den Ablaufplan individuell anpassen und die überfälligen Aufgaben auf einen späteren Zeitpunkt verschieben. Keine Sorge—Ihr bisheriger Fortschritt geht dabei nicht verloren.",
|
||||
"datesBanner.resetDatesBanner.button": "Verschiebe Abgabetermin",
|
||||
"learn.navigation.course.tabs.label": "Kursmaterial",
|
||||
"unit.bookmark.button.add.bookmark": "Diese Seite merken",
|
||||
"unit.bookmark.button.remove.bookmark": "Lesezeichen gesetzt",
|
||||
"learning.celebration.completed": "Sie haben gerade den ersten Abschnitt Ihres Studiums abgeschlossen.",
|
||||
"learning.celebration.congrats": "Glückwunsch!",
|
||||
"learning.celebration.earned": "Du hast es verdient!",
|
||||
"learning.celebration.emailSubject": "Ich bin auf dem Weg, {title} online mit {platform} abzuschließen!",
|
||||
"learning.celebration.forward": "Mach weiter",
|
||||
"learning.celebration.goalMet": "Du hast dein Ziel erreicht!",
|
||||
"learning.celebration.keepItUp": "Weiter so",
|
||||
"learning.celebration.share": "Nimm dir einen Moment Zeit, um zu feiern und deine Fortschritte zu teilen.",
|
||||
"learning.celebration.social": "Ich bin dabei, {title} online mit {platform} abzuschließen. Womit verbringst du deine Zeit mit Lernen?",
|
||||
"learning.celebration.goalCongrats": "Herzlichen Glückwunsch, Sie haben Ihr Lernziel von {nTimes} pro Woche erreicht.",
|
||||
"learning.celebration.setGoal": "Das Setzen eines Ziels kann Ihnen in Ihrem Kurs {strongText} helfen.",
|
||||
"calculator.instructions.button.label": "Rechner Anleitung",
|
||||
"calculator.instructions": "Ausführliche Informationen finden Sie im {expressions_link}.",
|
||||
"calculator.instructions.support.title": "Hilfe-Center",
|
||||
"calculator.instructions.useful.tips": "Nützliche Tipps:",
|
||||
"calculator.hint1": "Verwenden Sie Klammern (), um Ausdrücke deutlich zu machen. Sie können Klammern innerhalb anderer Klammern verwenden.",
|
||||
"calculator.hint2": "Bitte nutze keine Leerzeichen in deinem Ausdruck",
|
||||
"calculator.hint3": "Für Konstanten muss die Multiplikation explizit gekennzeichnet werden (Beispiel: 5*c)",
|
||||
"calculator.hint4": "Bei Einheitszusätzen muss die Einheit der Zahl direkt und ohne eingefuegte Leerzeichen folgen (Beispiel: 5m).",
|
||||
"calculator.hint5": "Für Funktionen muss der Name der Funktion gefolgt vom Funktionsargument in Klammern eingegeben werden.",
|
||||
"calculator.instruction.table.to.use.heading": "Zu nutzen",
|
||||
"calculator.instruction.table.type.heading": "Typ",
|
||||
"calculator.instruction.table.examples.heading": "Beispiele",
|
||||
"calculator.instruction.table.to.use.numbers": "Zahlen",
|
||||
"calculator.instruction.table.to.use.numbers.type1": "ganze Zahlen",
|
||||
"calculator.instruction.table.to.use.numbers.type2": "Brüche",
|
||||
"calculator.instruction.table.to.use.numbers.type3": "Dezimalstellen",
|
||||
"calculator.instruction.table.to.use.operators": "Operatoren",
|
||||
"calculator.instruction.table.to.use.operators.type1": "(addieren, subtrahieren, multiplizieren, dividieren)",
|
||||
"calculator.instruction.table.to.use.operators.type2": "(Potenz erheben)",
|
||||
"calculator.instruction.table.to.use.operators.type3": "(parallele Widerstände)",
|
||||
"calculator.instruction.table.to.use.constants": "Konstanten",
|
||||
"calculator.instruction.table.to.use.affixes": "Einheiten",
|
||||
"calculator.instruction.table.to.use.affixes.type": "Prozentzeichen (%)",
|
||||
"calculator.instruction.table.to.use.basic.functions": "Basisfunktionen",
|
||||
"calculator.instruction.table.to.use.trig.functions": "Trigonometrische Funktionen",
|
||||
"calculator.instruction.table.to.use.scientific.notation": "Wissenschaftliche Schreibweise",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type1": "{exponentSyntax} und der Exponent",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type2": "{notationSyntax}-Notation",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type3": "{notationSyntax} und der Exponent",
|
||||
"calculator.button.label": "Taschenrechner",
|
||||
"calculator.input.field.label": "Rechnereingabe",
|
||||
"calculator.submit.button.label": "Berechne",
|
||||
"calculator.result.field.label": "Rechenergebnis",
|
||||
"calculator.result.field.placeholder": "Ergebnis",
|
||||
"notes.button.show": "Notizen anzeigen",
|
||||
"notes.button.hide": "Notizen ausblenden",
|
||||
"courseExit.catalogSearchSuggestion": "Möchten Sie mehr erfahren? {searchOurCatalogLink}, um weitere Kurse und Programme zu finden, die Sie erkunden können.",
|
||||
"courseCelebration.certificateBody.available": "\nPräsentieren Sie Ihre Leistung noch heute auf LinkedIn oder in Ihrem Lebenslauf. \nSie können Ihr Zertifikat jetzt herunterladen und jederzeit von Ihrem \n{dashboardLink} und {profileLink} darauf zugreifen.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate.v2": "Dieser Kurs endet am {endDate}. Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {certAvailableDate} verfügbar.",
|
||||
"courseCelebration.certificateBody.unverified": "Um ein Zertifikat zu generieren, müssen Sie die ID-Verifizierung abschließen. {idVerificationSupportLink} jetzt.",
|
||||
"courseCelebration.certificateBody.upgradable": "Es ist noch nicht zu spät für ein Upgrade. Für {price} entsperren Sie den Zugriff auf alle benoteten Aufgaben in diesem Kurs. Nach Abschluss erhalten Sie ein verifiziertes Zertifikat, das ein wertvoller Nachweis ist, um Ihre Berufsaussichten zu verbessern und Ihre Karriere voranzutreiben oder Ihr Zertifikat in Schulbewerbungen hervorzuheben.",
|
||||
"courseCelebration.upgradeDiscountCodePrompt": "Mit dem Rabattcode {code} erhalten Sie an der Kasse {percent} % Rabatt!",
|
||||
"courseCelebration.recommendations.heading": "Bauen Sie Ihre Fähigkeiten mit diesen Kursen weiter aus!",
|
||||
"courseCelebration.recommendations.label": "Kurs",
|
||||
"courseCelebration.recommendations.formatting.list_join": "{style, select, punctuation {, } conjunction { {sp}und } other { }}",
|
||||
"courseCelebration.recommendations.browse_catalog": "Entdecken Sie weitere Kurse",
|
||||
"courseCelebration.recommendations.loading_recommendations": "Lade Empfehlungen",
|
||||
"courseCelebration.recommendations.card.schools.label": "Schulen und Partner",
|
||||
"courseCelebration.dashboardInfo": "Sie können auf diesen Kurs und seine Materialien auf Ihrem {dashboardLink} zugreifen.",
|
||||
"courseExit.programs.applyForCredit": "Kurs-Guthaben beantragen",
|
||||
"courseCelebration.certificateHeader.downloadable": "Ihr Zertifikat liegt vor!",
|
||||
"courseCelebration.certificateHeader.notAvailable": "Ihre Note und Ihr Zertifikatsstatus werden in Kürze verfügbar sein.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "Wenn Sie die Mindestnote erreicht haben, wird Ihr Zertifikat automatisch ausgestellt.",
|
||||
"courseCelebration.certificateHeader.unverified": "Sie müssen die Verifizierung abschließen, um Ihr Zertifikat zu erhalten.",
|
||||
"courseCelebration.certificateHeader.requestable": "Gratulation, Sie haben sich für ein Zertifikat qualifiziert!",
|
||||
"courseCelebration.certificateHeader.upgradable": "Führen Sie ein Upgrade durch, um ein verifiziertes Zertifikat zu erwerben",
|
||||
"courseCelebration.certificateImage": "Musterzertifikat",
|
||||
"courseCelebration.completedCourseHeader": "Sie haben Ihren Kurs abgeschlossen.",
|
||||
"courseCelebration.congratulationsHeader": "Glückwunsch!",
|
||||
"courseCelebration.congratulationsImage": "Vier Personen heben feierlich die Hände",
|
||||
"courseExit.courseInProgressDescription": "Es sieht so aus, als gäbe es in diesem Kurs weitere Inhalte, die in Zukunft veröffentlicht werden. Achten Sie auf E-Mail-Updates oder sehen Sie in Ihrem Kurs nach, wann diese Inhalte verfügbar sein werden.",
|
||||
"courseExit.courseInProgressHeader": "Weitere Inhalte folgen in Kürze!",
|
||||
"courseExit.dashboardLink": "Übersicht",
|
||||
"courseExit.endOfCourseDescription": "Leider haben Sie derzeit keinen Anspruch auf ein Zertifikat. Sie müssen eine Mindestnote erreichen, um sich für ein Zertifikat zu qualifizieren.",
|
||||
"courseExit.endOfCourseHeader": "Sie haben das Ende des Kurses erreicht!",
|
||||
"courseExit.endOfCourseTitle": "Ende des Kurses",
|
||||
"courseExit.idVerificationSupportLink": "Erfahren Sie mehr über die Identitätsprüfung",
|
||||
"courseCelebration.linkedinAddToProfileButton": "Zum LinkedIn-Profil hinzufügen",
|
||||
"courseExit.programs.microBachelors.learnMore": "Erfahren Sie mehr darüber, wie Ihr MicroBachelors-Zeugnis für eine Anrechnung beantragt werden kann.",
|
||||
"courseExit.programs.microMasters.learnMore": "Erfahren Sie mehr über das Verfahren zur Anwendung von MicroMasters-Zertifikaten auf Master-Abschlüsse.",
|
||||
"courseExit.programs.microMasters.mastersMessage": "Wenn Sie daran interessiert sind, Ihr MicroMasters-Zertifikat für ein Masterprogramm zu verwenden, können Sie noch heute loslegen!",
|
||||
"learn.sequence.navigation.complete.button": "Beenden Sie den Kurs",
|
||||
"courseExit.nextButton.endOfCourse": "Weiter (Ende natürlich)",
|
||||
"courseExit.profileLink": "Profil",
|
||||
"courseExit.programs.lastCourse": "Sie haben den letzten Kurs in {title} abgeschlossen!",
|
||||
"courseCelebration.requestCertificateBodyText": "Um auf Ihr Zertifikat zuzugreifen, fordern Sie es unten an.",
|
||||
"courseCelebration.requestCertificateButton": "Zertifikat anfordern",
|
||||
"courseExit.searchOurCatalogLink": "Suchen Sie in unserem Katalog",
|
||||
"courseCelebration.shareMessage": "Teilen Sie Ihren Erfolg in den sozialen Medien oder per E-Mail.",
|
||||
"courseExit.social.shareCompletionMessage": "Ich habe gerade {title} mit {platform} abgeschlossen!",
|
||||
"courseExit.upgradeButton": "Upgrade jetzt durchführen",
|
||||
"courseExit.upgradeLink": "Upgrade jetzt durchführen",
|
||||
"courseCelebration.verificationPending": "Ihre ID-Überprüfung steht noch aus und Ihr Zertifikat ist verfügbar, sobald es genehmigt wurde.",
|
||||
"courseExit.verifiedCertificateSupportLink": "Erfahren Sie mehr über verifizierte Zertifikate",
|
||||
"courseCelebration.verifyIdentityButton": "ID jetzt verifizieren",
|
||||
"courseCelebration.viewCertificateButton": "Sehen Sie sich mein Zertifikat an",
|
||||
"courseExit.viewCourseScheduleButton": "Kursplan ansehen",
|
||||
"courseExit.viewCoursesButton": "Sehen Sie sich meine Kurse an",
|
||||
"courseExit.viewGradesButton": "Noten ansehen",
|
||||
"courseExit.programCompletion.dashboardMessage": "Um Ihren Zertifikatsstatus anzuzeigen, sehen Sie im Abschnitt "Programme" Ihres {programLink} nach.",
|
||||
"courseExit.upgradeFootnote": "Der Zugriff auf diesen Kurs und seine Materialien ist auf Ihrem Dashboard bis {expirationDate} verfügbar. Um diese Zugriffsfrist zu verlängern, {upgradeLink}.",
|
||||
"learn.course.license.allRightsReserved.text": "Alle Rechte vorbehalten",
|
||||
"learn.course.license.creativeCommons.terms.preamble": "Von Creative Commons lizenzierte Inhalte mit den folgenden Bedingungen:",
|
||||
"learn.course.license.creativeCommons.terms.by": "Attribution",
|
||||
"learn.course.license.creativeCommons.terms.nc": "Nicht-kommerziell",
|
||||
"learn.course.license.creativeCommons.terms.nd": "No Derivatives",
|
||||
"learn.course.license.creativeCommons.terms.sa": "Share Alike",
|
||||
"learn.course.license.creativeCommons.terms.zero": "Keine Bedingungen",
|
||||
"learn.course.license.creativeCommons.text": "Some Rights Reserved",
|
||||
"learn.breadcrumb.navigation.course.home": "Kurs",
|
||||
"notification.tray.container": "Benachrichtigungsfach",
|
||||
"notification.open.button": "Benachrichtigungsleiste anzeigen",
|
||||
"notification.close.button": "Schließen Sie die Benachrichtigungsleiste",
|
||||
"responsive.close.notification": "Zurück zum Kurs",
|
||||
"notification.tray.title": "Benachrichtigungen",
|
||||
"notification.tray.no.message": "Sie haben derzeit keine neuen Benachrichtigungen.",
|
||||
"learn.contentLock.content.locked": "Inhalt nicht zugänglich",
|
||||
"learn.contentLock.complete.prerequisite": "Sie müssen die Voraussetzung erfüllen: ''{prereqSectionName}'', um auf diesen Inhalt zugreifen zu können.",
|
||||
"learn.contentLock.goToSection": "Gehen Sie zum Abschnitt „Voraussetzungen“.",
|
||||
"learn.hiddenAfterDue.gradeAvailable": "Wenn Sie diese Aufgabe abgeschlossen haben, ist Ihre Note im {progressPage} verfügbar.",
|
||||
"learn.hiddenAfterDue.header": "Die Einschreibungsfrist zu diesem Kurs ist abgelaufen.",
|
||||
"learn.hiddenAfterDue.description": "Diese Aufgabe ist nicht mehr verfügbar, da die Frist abgelaufen ist.",
|
||||
"learn.hiddenAfterDue.progressPage": "Fortschrittsseite",
|
||||
"learn.honorCode.content": "Ehrlichkeit und akademische Integrität sind wichtig für {siteName} und die Institutionen, die Kurse und Programme auf der {siteName}-Website anbieten. Indem ich unten auf „Ich stimme zu“ klicke, bestätige ich, dass ich die {link} für die {siteName}-Site gelesen und verstanden habe und mich daran halten werde.",
|
||||
"learn.honorCode.name": "Verhaltenskodex",
|
||||
"learn.honorCode.cancel": "Löschen",
|
||||
"learn.honorCode.agree": "Ich stimme zu",
|
||||
"learn.lockPaywall.title": "Benotete Aufgaben sind gesperrt",
|
||||
"learn.lockPaywall.content": "Führen Sie ein Upgrade durch, um Zugriff auf gesperrte Funktionen wie diese zu erhalten und Ihren Kurs optimal zu nutzen.",
|
||||
"learn.lockPaywall.content.pastExpiration": "Die Upgrade-Frist für diesen Kurs ist abgelaufen. Um ein Upgrade durchzuführen, melden Sie sich für die nächste verfügbare Sitzung an.",
|
||||
"learn.lockPaywall.courseDetails": "Kursdetails anzeigen",
|
||||
"learn.lockPaywall.example.alt": "Beispielzertifikat",
|
||||
"learn.lockPaywall.list.intro": "Wenn Sie ein Upgrade durchführen, können Sie:",
|
||||
"learn.header.h2.placeholder": "Überschriften der Ebene 2 können in Zukunft von Kursanbietern erstellt werden.",
|
||||
"learn.course.load.failure": "Beim Laden dieses Kurses ist ein Fehler aufgetreten.",
|
||||
"learn.loading.honor.codk": "Ehrencode-Nachrichten werden geladen...",
|
||||
"learn.loading.content.lock": "Nachrichten zu gesperrten Inhalten werden geladen...",
|
||||
"learn.loading.learning.sequence": "Lernsequenz wird geladen...",
|
||||
"learn.sequence.no.content": "Hier gibt es keinen Inhalt.",
|
||||
"learn.sequence.navigation.next.button": "Weiter",
|
||||
"learn.sequence.navigation.next.up.button": "Als nächstes: {title}",
|
||||
"learn.sequence.navigation.previous.button": "Zurück",
|
||||
"learn.course.sequence.navigation.mobile.menu": "{current} von {total}",
|
||||
"learn.sequence.share.button": "Teilen Sie diesen Inhalt",
|
||||
"learn.sequence.share.modal.title": "Titel",
|
||||
"learn.sequence.share.modal.body": "Kopieren Sie den Link unten, um diesen Inhalt zu teilen.",
|
||||
"learn.sequence.share.quote": "Hier ist ein lustiger Clip aus einem Kurs, den ich bei @edXonline belege.\n",
|
||||
"discussions.sidebar.title": "Diskussionen",
|
||||
"discussions.sidebar.open.button": "Diskussionsablage anzeigen",
|
||||
"learn.redirect.interstitial.message": "Umleitung...",
|
||||
"learn.loading.error": "Fehler: {error}",
|
||||
"learning.celebration.emailBody": "Womit verbringst du deine Zeit mit Lernen?",
|
||||
"learning.social.shareEmail": "Teilen Sie Ihren Fortschritt per E-Mail.",
|
||||
"learning.social.shareService": "Teilen Sie Ihren Fortschritt auf {service}.",
|
||||
"general.altText.close": "Schließen",
|
||||
"learning.logistration.register": "registrieren",
|
||||
"learning.logistration.login": "Anmelden",
|
||||
"general.signIn.sentenceCase": "Anmelden",
|
||||
"learn.course.tabs.navigation.overflow.menu": "Mehr...",
|
||||
"learning.offer.screenReaderPrices": "Originalpreis: {originalPrice}, Rabattpreis: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "Originalpreis: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "Upgrade für {pricing}",
|
||||
"learning.upgradeNowButton.buttonText": "Upgrade jetzt für {pricing}",
|
||||
"learning.generic.upgradeNotification.expirationAccessLoss.progress": "einschließlich etwaiger Fortschritte",
|
||||
"learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "Vorteile des Upgrades",
|
||||
"learning.generic.upgradeNotification.expirationAccessLoss": "Am {date} verlieren Sie jeglichen Zugriff auf diesen Kurs, {includingAnyProgress}.",
|
||||
"learning.generic.upgradeNotification.expirationVerifiedCert": "Durch das Upgrade Ihres Kurses können Sie ein verifiziertes Zertifikat erwerben und zahlreiche Funktionen freischalten. Mehr unter {benefitsOfUpgrading}.",
|
||||
"learning.generic.upgradeNotification.pastExpiration.content": "Die Upgrade-Frist für diesen Kurs ist abgelaufen. Um ein Upgrade durchzuführen, melden Sie sich für die nächste verfügbare Sitzung an.",
|
||||
"learning.generic.upgradeNotification.expirationDays": "{dayCount, number} {dayCount, plural, one {Tag} other {Tage}} übrig",
|
||||
"learning.generic.upgradeNotification.expirationHours": "{hourCount, number} {hourCount, plural, one {Stunde} other {Stunden}} übrig",
|
||||
"learning.generic.upgradeNotification.expirationMinutes": "Weniger als 1 Stunde übrig",
|
||||
"learning.generic.upgradeNotification.expiration": "Der Kurszugriff läuft am {date} ab",
|
||||
"learning.generic.upgradeNotification.pastExpiration.banner": "Upgrade-Frist am {date} abgelaufen",
|
||||
"learning.generic.upgradeNotification.firstTimeLearnerDiscount": "{percentage}% Rabatt für Erstlerner",
|
||||
"learning.generic.upgradeNotification.accessExpiration": "Führen Sie noch heute ein Upgrade für Ihren Kurs durch",
|
||||
"learning.generic.upgradeNotification.accessExpirationUrgent": "Ablauf des Kurszugriffs",
|
||||
"learning.generic.upgradeNotification.accessExpirationPast": "Ablauf des Kurszugriffs",
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "Streben Sie ein verifiziertes Zertifikat an",
|
||||
"learning.generic.upgradeNotification.code": "Verwenden Sie den Code {code} an der Kasse",
|
||||
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "verifiziertes Zertifikat",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Bekommen Sie ein {verifiedCertLink} für den Abschluss, zur Nutzung in Ihrem Lebenslauf",
|
||||
"learning.generic.upsell.unlockGradedBullet.gradedAssignments": "benotete Aufgaben",
|
||||
"learning.generic.upsell.unlockGradedBullet": "Entsperren Sie Ihren Zugang zu allen Kursaktivitäten, einschließlich {gradedAssignmentsInBoldText}",
|
||||
"learning.generic.upsell.fullAccessBullet.fullAccess": "Voller Zugriff",
|
||||
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} auf Kursinhalte und -materialien, auch nach Kursende",
|
||||
"learning.generic.upsell.supportMissionBullet.mission": "Mission",
|
||||
"learning.generic.upsell.supportMissionBullet": "Unterstützen Sie unser {missionInBoldText} unter {siteName}",
|
||||
"masquerade-widget.userName.error.generic": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"masquerade-widget.userName.input.placeholder": "Benutzername oder E-Mail-Adresse",
|
||||
"masquerade-widget.userName.input.label": "Geben Sie vor, dieser Benutzer zu sein",
|
||||
"tours.abandonTour.launchTourCheckpoint.body": "Wissen Sie nicht weiter? Die jederzeit verfügbare Tour hilft Ihnen mit einfachen Tipps, das Beste aus Ihrem Lernerlebnis zu machen.",
|
||||
"tours.sequenceNavigationCheckpoint.body": "Die obere Leiste in Ihrem Kurs erlaubt Ihnen, zu verschiedenen Abschnitten zu springen. Sie zeigt Ihnen auch, was auf Sie zukommt.",
|
||||
"tours.existingUserTour.launchTourCheckpoint.body": "Wir haben kürzlich die Benutzerführung geändert. Brauchen Sie Hilfe, sich wieder zurecht zu finden? Machen Sie eine Tour, um mehr zu erfahren.",
|
||||
"tours.button.dismiss": "Tour Beenden",
|
||||
"tours.button.next": "Weiter",
|
||||
"tours.button.okay": "Okay",
|
||||
"tours.button.beginTour": "Rundgang beginnen",
|
||||
"tours.button.launchTour": "Rundgang starten",
|
||||
"tours.newUserModal.body": "Kommen Sie mit auf einen kurzen Rundgang durch {siteName}, damit Sie Ihren Kurs optimal nutzen können.",
|
||||
"tours.newUserModal.title.welcome": "Willkommen bei Ihrem",
|
||||
"tours.button.skipForNow": "Jetzt Nicht",
|
||||
"tours.datesCheckpoint.body": "Wichtige Termine können Ihnen helfen, am Ball zu bleiben.",
|
||||
"tours.datesCheckpoint.title": "Behalten Sie wichtige Termine im Auge",
|
||||
"tours.outlineCheckpoint.body": "Sie können Abschnitte des Kurses anhand der folgenden Gliederung erkunden.",
|
||||
"tours.outlineCheckpoint.title": "Machen Sie den Kurs!",
|
||||
"tours.tabNavigationCheckpoint.body": "Sie können über diese Registerkarten andere Kursinformationen erreichen, wie z. B. Ihren Fortschritt, Ihren Lehrplan, usw.",
|
||||
"tours.tabNavigationCheckpoint.title": "Zusätzliches Kursmaterial",
|
||||
"tours.upgradeCheckpoint.body": "Arbeiten Sie auf ein Zertifikat hin und erhalten Sie vollen Zugriff auf Kursmaterialien. Jetzt upgraden!",
|
||||
"tours.upgradeCheckpoint.title": "Schalten Sie Ihren Kurs frei",
|
||||
"tours.weeklyGoalsCheckpoint.body": "Wenn Sie sich ein Ziel setzen, ist es wahrscheinlicher, dass Sie Ihren Kurs abschließen.",
|
||||
"tours.weeklyGoalsCheckpoint.title": "Legen Sie ein Kursziel fest",
|
||||
"tours.newUserModal.title": "{welcome} im {siteName} Kurs!",
|
||||
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
|
||||
"learning.effortEstimation.activities": "{activityCount, plural, one {# Aktivität} other {# Aktivitäten}}",
|
||||
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# Mindest} other {# Minuten}}",
|
||||
"learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# Minute} other {# Protokoll}}",
|
||||
"learning.streakCelebration.congratulations": "Glückwunsch!",
|
||||
"learning.streakCelebration.body": "Weiter so, das läuft ja super!",
|
||||
"learning.streakCelebration.button": "Weiter so",
|
||||
"learning.streakCelebration.buttonSrOnly": "Dialogbox schließen und fortfahren",
|
||||
"learning.streakCelebration.buttonAA759": "Weiter im Kurs",
|
||||
"learning.streakCelebration.header": "-Tagesserie",
|
||||
"learning.streakCelebration.factoidABoldedSection": "haben eine 20-mal höhere Wahrscheinlichkeit, ihren Kurs zu bestehen",
|
||||
"learning.streakCelebration.factoidBBoldedSection": "absolvieren durchschnittlich 5x so viele Kursinhalte",
|
||||
"learning.streakCelebration.streakDiscountMessage": "Sie haben einen Rabatt von {percent} % freigeschaltet, wenn Sie diesen Kurs nur für eine begrenzte Zeit upgraden.",
|
||||
"learning.streakcelebration.factoida": "Benutzer, die {streak_length} Tage hintereinander {bolded_section} lernen, als diejenigen, die dies nicht tun.",
|
||||
"learning.streakcelebration.factoidb": "Benutzer, die {streak_length} Tage hintereinander {bolded_section} lernen, im Vergleich zu denen, die dies nicht tun.",
|
||||
"learning.streakCelebration.streakCelebrationCouponEndDateMessage": "Endet {date}.",
|
||||
"learning.loading.failure": "Beim Laden dieses Kurses ist ein Fehler aufgetreten.",
|
||||
"learning.loading": "Kursseite wird geladen…"
|
||||
}
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Apertura de la integración: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Revisar las instrucciones y los requisitos del sistema",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Inducción vencida",
|
||||
"learning.outline.sequence-due": "Fecha límite para {description}: {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "Fecha límite para {description}: {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "Para generar un certificado, debes completar la verificación de identidad. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Muestra tu logro en LinkedIn o en tu currículum. Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu panel de estudiante y tu perfil.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Las calificaciones finales y los certificados obtenidos están programados para estar disponibles después de {endDate}.",
|
||||
|
||||
452
src/i18n/messages/fa_IR.json
Normal file
452
src/i18n/messages/fa_IR.json
Normal file
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"learning.accessExpiration.deadline": "برای دسترسی نامحدود به دوره آموزشی تا زمانی که در وبگاه است، تا {date} ارتقا دهید.",
|
||||
"learning.accessExpiration.header": "دسترسی حسابرسی منقضی میشود {date}",
|
||||
"learning.accessExpiration.body": "شما همه دسترسی به این دوره آموزشی خود همچون پیشرفت خود را در {date} از دست میدهید.",
|
||||
"instructorToolbar.pageBanner.courseHasExpired": "این فرد، دیگر به این دوره آموزشی دسترسی ندارد. دسترسی آنها در {date} منقضی شد.",
|
||||
"learning.accessExpiration.upgradeNow": "اکنون روزآمد کنید.",
|
||||
"learning.activeEnterprise.alert": "{changeActiveEnterprise}.",
|
||||
"learning.activeEnterprise.change.alert": "تغییر شرکت هماکنون",
|
||||
"learning.outline.alert.start.short": "دوره آموزشی در {timeRemaining} در {courseStartTime} آغاز میشود.",
|
||||
"learning.outline.alert.end.long": "این دوره آموزشی در {timeRemaining} در {courseEndDate} به پایان میرسد.",
|
||||
"learning.outline.alert.end.calendar": "فراموش نکنید که یک یادآور تقویم بیفزایید!",
|
||||
"instructorToolbar.pageBanner.courseHasNotStarted": "این فرد هنوز به این دوره آموزشی دسترسی ندارد. دوره آموزشی در تاریخ {date} شروع میشود.",
|
||||
"learning.enrollment.alert": "برای دیدن محتوای دوره آموزشی باید در آن ثبتنام کرده باشید.",
|
||||
"learning.staff.enrollment.alert": "شما در حال مشاهده دوره آموزشی بهعنوان عضو سامانه هستید اما ثبتنام نکردهاید.",
|
||||
"learning.enrollment.enrollNow.Inline": "الان ثبتنام کنید",
|
||||
"learning.enrollment.enrollNow.Sentence": "الان ثبتنام کنید.",
|
||||
"learning.enrollment.success": "شما در این دوره آموزشی ثبتنام کردید.",
|
||||
"account-activation.alert.button": "ادامه در {siteName}",
|
||||
"account-activation.alert.message": "ما رایانامهای به {boldEmail} با پیوندی برای فعالسازی حساب کاربری شما ارسال کردیم. نمیتوانید آن را پیدا کنید؟ پوشه هرزنامه یا {sendEmailTag} را بررسی کنید.",
|
||||
"account-activation.resend.link": "ارسال مجدد رایانامه",
|
||||
"learning.logistration.alert": "برای دیدن محتوای دوره آموزشی، {signIn} یا {register}.",
|
||||
"account-activation.alert.title": "حساب کاربری خود را فعال کنید تا بتوانید دوباره وارد شوید",
|
||||
"learn.sequence.entranceExamTextNotPassing": "برای دسترسی به منابع آموزشی، باید در این آزمون امتیاز {entranceExamMinimumScorePct}% یا بالاتر کسب کنید. امتیاز فعلی شما {entranceExamCurrentScore}٪ است.",
|
||||
"learn.sequence.entranceExamTextPassed": "امتیاز شما {entranceExamCurrentScore}% است. شما در آزمون ورودی پذیرفته شدهاید.",
|
||||
"learning.dates.badge.completed": "کاملشده",
|
||||
"learning.dates.badge.dueNext": "موعد بعدی",
|
||||
"learning.dates.badge.pastDue": "سررسید",
|
||||
"learning.dates.title": "تاریخهای مهم",
|
||||
"learning.dates.badge.today": "امروز",
|
||||
"learning.dates.badge.unreleased": "هنوز منتشر نشدهاست",
|
||||
"learning.dates.badge.verifiedOnly": "فقط تاییدشده",
|
||||
"learning.goals.unsubscribe.contact": "تماس با پشتیبانی ",
|
||||
"learning.goals.unsubscribe.description": "از این پس رایانامه یادآوری درباره هدف خود برای {courseTitle} دریافت نخواهید کرد.",
|
||||
"learning.goals.unsubscribe.errorHeader": "مشکلی پیش آمد",
|
||||
"learning.goals.unsubscribe.goToDashboard": "بازگشت به پیشخوان",
|
||||
"learning.goals.unsubscribe.header": "اشتراکتان را از هشدار یادآوری هدف لغو کردهاید",
|
||||
"learning.goals.unsubscribe.loading": "در حال لغو آبونمان...",
|
||||
"learning.goals.unsubscribe.errorDescription": "ما نتوانستیم اشتراک شما را از رایانامههای هشدار هدف لغو کنیم. لطفاً بعداً دوباره تلاش کنید یا برای راهنمایی {با پشتیبانی تماس بگیرید}.",
|
||||
"learning.outline.alert.cert.earnedNotAvailable": "این دوره آموزشی در {courseEndDateFormatted} پایان مییابد. نمرات نهایی و گواهیهای کسبشده طبق برنامه پس از {certificateAvailableDate} فراهم خواهند بود.",
|
||||
"cert.alert.earned.unavailable.header.v2": "وضعیت نمره و گواهی شما بهزودی در دسترس خواهد بود.",
|
||||
"cert.alert.earned.ready.header": "تبریک میگوییم! گواهی شما آماده است.",
|
||||
"cert.alert.notPassing.header": "شما هنوز واجد شرایط دریافت گواهی نیستید",
|
||||
"cert.alert.notPassing.button": "مشاهده نمرات",
|
||||
"learning.outline.alert.end.short": "این دوره آموزشی در {timeRemaining} و {courseEndTime} به پایان میرسد.",
|
||||
"alert.enroll": "برای دسترسی به دوره آموزشی کامل.",
|
||||
"learning.privateCourse.signInOrRegister": "{signIn} یا {register} و سپس در این دوره آموزشی ثبتنام کنید.",
|
||||
"learning.outline.alert.scheduled-content.heading": "محتوای بیشتری در آینده در اختیار شما قرار خواهد گرفت!",
|
||||
"learning.outline.alert.scheduled-content.body": "این دوره آموزشی محتوای بیشتری در آینده خواهد داشت. برای روزآمدسازیها و بررسی محتوای دوره آموزشی را از طریق صندوق رایانامه بررسی کنید.",
|
||||
"learning.outline.alert.scheduled-content.button": "مشاهده برنامه آموزشی دوره",
|
||||
"learning.outline.dates.all": "مشاهد همه تاریخهای دوره آموزشی",
|
||||
"learning.outline.goalButton.casual.text": "1 روز در هفته",
|
||||
"learning.outline.goalButton.screenReader.text": "گاهبهگاه",
|
||||
"learning.outline.certificateAlt": "گواهی نمونه",
|
||||
"learning.outline.collapseAll": "بستن همه موارد",
|
||||
"learning.outline.completedAssignment": "کامل شده",
|
||||
"learning.outline.completedSection": "بخش کاملشده",
|
||||
"learning.outline.dates": "تاریخهای مهم",
|
||||
"learning.outline.editGoal": "ویرایش هدف",
|
||||
"learning.outline.expandAll": "گسترش همه موارد",
|
||||
"learning.outline.goal": "هدف",
|
||||
"learning.outline.goalReminderDetail": "اگر متوجه شویم که کاملاً به هدف خود نرسیدهاید، رایانامه یادآوری برای شما ارسال خواهیم کرد.",
|
||||
"learning.outline.goalUnsure": "هنوز مطمئن نیستم",
|
||||
"learning.outline.handouts": "جزوات دوره",
|
||||
"learning.outline.incompleteAssignment": "ناتمام",
|
||||
"learning.outline.incompleteSection": "بخش ناتمام",
|
||||
"learning.outline.goalButton.intense.text": "5 روز در هفته",
|
||||
"learning.outline.goalButton.intense.title": "شدید",
|
||||
"learning.outline.learnMore": "بیشتر بدانید",
|
||||
"learning.outline.altText.openSection": "بازکردن",
|
||||
"learning.proctoringPanel.header": "این دوره آموزشی شامل آزمونهای حفاظتشده میشود",
|
||||
"learning.outline.goalButton.regular.text": "3 روز در هفته",
|
||||
"learning.outline.goalButton.regular.title": "منظم",
|
||||
"learning.outline.resumeBlurb": "از جایی که ترک کردید ادامه دهید",
|
||||
"learning.outline.resume": "ادامه دوره آموزشی",
|
||||
"learning.outline.setGoal": "برای آغاز، با انتخاب گزینه زیر که بهترین برنامه آموزشی شما را توصیف میکند، هدف خود را از شرکت در دوره آموزشی تعیین کنید.",
|
||||
"learning.outline.setGoalReminder": "یادآور هدف تنظیم کنید",
|
||||
"learning.outline.goalButton.casual.title": "سبک هدف یادگیری تعیین کنید.",
|
||||
"learning.outline.setWeeklyGoal": "یک هدف یادگیری هفتگی تعیین کنید",
|
||||
"learning.outline.setWeeklyGoalDetail": "تعیین هدف به شما انگیزه میدهد تا دوره آموزشی را به پایان برسانید. همیشه امکان تغییر آن را دارید.",
|
||||
"learning.outline.start": "آغاز دوره آموزشی ",
|
||||
"learning.outline.startBlurb": "دوره آموزشی خود را از امروز شروع کنید",
|
||||
"learning.outline.tools": "ابزارهای دوره آموزشی",
|
||||
"learning.outline.upgradeButton": "ارتقا ({symbol}{price})",
|
||||
"learning.outline.upgradeTitle": "به دنبال کسب یک گواهی تأییدشده باشید",
|
||||
"learning.outline.welcomeMessage": "پیام خوشآمدگویی",
|
||||
"learning.outline.welcomeMessageShowMoreButton": "بیشتر",
|
||||
"learning.outline.welcomeMessageShowLessButton": "کمتر",
|
||||
"learning.outline.goalWelcome": "خوش آمدید به :",
|
||||
"learning.proctoringPanel.status.notStarted": "شروع نشده",
|
||||
"learning.proctoringPanel.status.started": "شروع شده",
|
||||
"learning.proctoringPanel.status.submitted": "ارائه شده",
|
||||
"learning.proctoringPanel.status.verified": "تاییدشده",
|
||||
"learning.proctoringPanel.status.rejected": "رد شده",
|
||||
"learning.proctoringPanel.status.error": "خطا",
|
||||
"learning.proctoringPanel.status.otherCourseApproved": "در دوره آموزشی دیگری تایید شد",
|
||||
"learning.proctoringPanel.status.expiringSoon": "بهزودی منقضی میشود",
|
||||
"learning.proctoringPanel.status.expired": "منقضیشده",
|
||||
"learning.proctoringPanel.status": "وضعیت ورودی جاری:",
|
||||
"learning.proctoringPanel.message.notStarted": "شما آزمون ورودی خود را آغاز نمودهاید.",
|
||||
"learning.proctoringPanel.message.started": "شما امتحان آزمایشی خود را آغاز کردهاید.",
|
||||
"learning.proctoringPanel.message.submitted": "شما آزمون ورودی خود را ارسال کردهاید.",
|
||||
"learning.proctoringPanel.message.verified": "آزمون ورودی شما در این دوره آموزشی تایید شدهاست.",
|
||||
"learning.proctoringPanel.message.rejected": "آزمون ورودی شما رد شده است. لطفاً دوباره سعی کنید.",
|
||||
"learning.proctoringPanel.message.error": "خطایی در طول آزمون آزمایشی شما رخ داده است. لطفاً این آزمون را دوباره امتحان کنید.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "آزمون آزمایشی شما در دوره آموزشی دیگری تایید شده است.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "اگر دستگاه شما تغییر کرده است، توصیه میکنیم که امتحان داخلی این دوره را تکمیل کنید تا مطمئن شوید که راهاندازی شما همچنان با الزامات پیشبینی شده تطابق دارد.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "پرونده شخصی ورود شما تایید شد. اما بهزودی منقضی میشود. لطفاً گام های ورود به سامانه را تکمیل کنید تا مطمئن شوید که میتوانید آزمونهای همراه با نظارت را ادامه دهید.",
|
||||
"learning.proctoringPanel.message.expired": "وضعیت ورود شما منقضی شدهاست. لطفاً برای ادامه شرکت در آزمونهای حفاظتشده، ورود را مجددا تکمیل کنید.",
|
||||
"learning.proctoringPanel.generalInfo": "شما باید پیش از شرکت در هر آزمون آزمایشی، فرآیند حفاظت را تکمیل کنید.",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "پرونده شخصی ارسالی شما در حال بررسی است.",
|
||||
"learning.proctoringPanel.generalTime": "بررسی پرونده ورودی ممکن است 2+ روز کاری طول بکشد.",
|
||||
"learning.proctoringPanel.onboardingButton": "ورودی کامل",
|
||||
"learning.proctoringPanel.onboardingPracticeButton": "مشاهده آزمون جاری",
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "آزمایش باز میشود: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "دستورالعملها و سامانه مورد نیاز را بررسی کنید",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "ورودی تاریخ گذشته",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "برای تهیه گواهی، باید تأیید هویت را تکمیل کنید. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "امروز موفقیت خود را در لینکدین یا رزومه خود به نمایش بگذارید. اکنون میتوانید گواهی خود را بارگیری کنید و هر زمان که بخواهید از پیشخوان و پرونده شخصر خود به آن دسترسی داشته باشید.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "نمرات نهایی و همه گواهیهای کسبشده قرار است پس از {endDate} در دسترس باشند.",
|
||||
"progress.certificateStatus.notPassingHeader": "وضعیت گواهی",
|
||||
"progress.certificateStatus.notPassingBody": "برای اینکه واجد شرایط دریافت گواهی باشید، باید نمره قبولی کسب کنید.",
|
||||
"progress.certificateStatus.inProgressHeader": "محتوای بیشتری در آینده در اختیار شما قرار خواهد گرفت!",
|
||||
"progress.certificateStatus.inProgressBody": "به نظر میرسد مطالب بیشتری در این دوره آموزشی وجود دارد که در آینده منتشر خواهد شد. از طریق رایانامه، روزآمدسازیها را به اطلاع شما میرسانیم یا دوره آموزشی خود را برای دسترسی به محتوا دوباره بررسی کنید.",
|
||||
"progress.certificateStatus.requestableHeader": "وضعیت گواهی",
|
||||
"progress.certificateStatus.requestableBody": "تبریک می گوییم، شما واجد شرایط دریافت گواهی هستید! برای دسترسی به گواهی خود، آن را در زیر درخواست کنید.",
|
||||
"progress.certificateStatus.requestableButton": "درخواست گواهی",
|
||||
"progress.certificateStatus.unverifiedHeader": "وضعیت گواهی",
|
||||
"progress.certificateStatus.unverifiedButton": "تایید شناسه",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "تأیید هویت شما در حالت انتظار است و گواهی پس از تأیید در دسترس خواهد بود.",
|
||||
"progress.certificateStatus.downloadableHeader": "گواهی شما در دسترس است!",
|
||||
"progress.certificateStatus.viewableButton": "مشاهده گواهی من",
|
||||
"progress.certificateStatus.notAvailableHeader": "وضعیت گواهی",
|
||||
"progress.certificateBody.notAvailable.endDate": "نمرات نهایی و همه گواهیهای کسبشده قرار است پس از {endDate} در دسترس باشند.",
|
||||
"progress.certificateStatus.upgradeHeader": "گواهی کسب کنید",
|
||||
"progress.certificateStatus.upgradeBody": "شما در مسیر بررسی هستید و واجد شرایط دریافت گواهی نیستید. بهمنظور تلاش برای دریافت گواهی، امروز دوره آموزشی خود را ارتقا دهید.",
|
||||
"progress.certificateStatus.upgradeButton": "اکنون ارتقا دهید",
|
||||
"progress.certificateStatus.unverifiedHomeHeader.v2": "هویت خود را تأیید کنید تا واجد شرایط دریافت گواهی شوید.",
|
||||
"progress.certificateStatus.unverifiedHomeButton": "تایید هویت من",
|
||||
"progress.certificateStatus.unverifiedHomeBody": "برای تهیه گواهی برای این دوره آموزشی، باید مراحل تایید هویت را تکمیل کنید.",
|
||||
"progress.completion.donut.label": "تکمیل شد",
|
||||
"progress.completion.body": "این نشان میدهد که چه مقدار از محتوای دوره آموزشی را تکمیل کرده اید. توجه کنید که برخی از محتواها ممکن است هنوز منتشر نشده باشند.",
|
||||
"progress.completion.tooltip.locked": "محتوایی که تکمیل کردهاید.",
|
||||
"progress.completion.header": "پایان دوره آموزشی",
|
||||
"progress.completion.tooltip": "محتوایی که به آن دسترسی دارید و آن را تکمیل نکردهاید.",
|
||||
"progress.completion.tooltip.complete": "محتوایی که قفل شده است و فقط برای کسانی در دسترس است که ارتقا میدهند.",
|
||||
"progress.completion.donut.percentComplete": "شما {percent}% از مطالب این دوره آموزشی را تکمیل کردهاید.",
|
||||
"progress.completion.donut.percentIncomplete": "شما {percent}% از محتوای این دوره آموزشی را که به آن دسترسی دارید تکمیل نکردهاید.",
|
||||
"progress.completion.donut.percentLocked": "{percent}% محتوای این دوره قفل شده است و فقط در دسترس کسانی است که ارتقاء میدهند.",
|
||||
"progress.creditInformation.creditNotEligible": "شما دیگر واجد شرایط دریافت اعتبار در این دوره آموزشی نیستید. درباره {creditLink} اطلاعات بیشتری کسب کنید.",
|
||||
"progress.creditInformation.creditEligible": "\nشما شرایط لازم برای اعتبار را در این دوره آموزشی دارید. \nبرای خرید اعتبار دوره آموزشی به {dashboardLink} خود بروید. یا درباره {creditLink} اطلاعات بیشتری کسب کنید.",
|
||||
"progress.creditInformation.creditPartialEligible": "شما هنوز شرایط لازم برای دریافت اعتبار را کسب نکردهاید. درباره {creditLink} بیشتر بدانید.",
|
||||
"progress.creditInformation.completed": "کامل شده",
|
||||
"progress.creditInformation.courseCredit": " اعتبار دوره آموزشی",
|
||||
"progress.creditInformation.minimumGrade": "حداقل نمره برای اعتبار ({minGrade}%)",
|
||||
"progress.creditInformation.requirementsHeader": "الزامات اعتبار دوره آموزشی",
|
||||
"progress.creditInformation.upcoming": "در آینده منتشر میشود",
|
||||
"progress.creditInformation.verificationFailed": "راستیآزمایی موفق نبود",
|
||||
"progress.creditInformation.verificationSubmitted": "راستیآزمایی تایید شد",
|
||||
"progress.ungradedAlert": "برای پیشرفت در جنبههای بارمبندی نشده دوره آموزشی، {outlineLink} خود را مشاهده کنید.",
|
||||
"progress.footnotes.droppableAssignments": "کمترین امتیاز {numDroppable, plural, one{# {assignmentType} is} other{# {assignmentType} امتیاز}} حذف شده است.",
|
||||
"progress.assignmentType": "نوع تکلیف",
|
||||
"progress.footnotes.backToContent": "بازگشت به محتوا",
|
||||
"progress.courseGrade.body": "این نشاندهنده نمره وزنی شما در برابر نمره مورد نیاز برای گذراندن این دوره آموزشی است.",
|
||||
"progress.courseGrade.gradeBar.altText": "نمره فعلی شما {currentGrade}٪ است. کسب نمره وزنی {passingGrade}% برای قبولی در این دوره آموزشی الزامی است.",
|
||||
"progress.courseGrade.footer.generic.passing": "در حال حاضر شما این دوره آموزشی هستید",
|
||||
"progress.courseGrade.footer.nonPassing": "نمره وزنی {passingGrade}% برای قبولی در این دوره آموزشی الزامی است",
|
||||
"progress.courseGrade.footer.passing": "شما در حال گذراندن این دوره آموزشی با نمره {letterGrade} ({minGrade}-{maxGrade}%) هستید",
|
||||
"progress.courseGrade.preview.headerLocked": "ویژگی قفلشده",
|
||||
"progress.courseGrade.preview.headerLimited": "ویژگی محدود",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "پیشنمایش یک",
|
||||
"progress.courseGrade.preview.body.unlockCertificate": "برای مشاهده نمرات و تلاش برای دریافت گواهی، قفل را باز کنید.",
|
||||
"progress.courseGrade.partialpreview.body.unlockCertificate": "برای کار با هدف دریافت گواهی، قفلگشایی کنید.",
|
||||
"progress.courseGrade.preview.body.upgradeDeadlinePassed": "مهلت ارتقا در این دوره آموزشی به پایان رسیده است.",
|
||||
"progress.courseGrade.preview.button.upgrade": "اکنون ارتقا دهید",
|
||||
"progress.courseGrade.gradeRange.tooltip": "دامنه نمره در این دوره آموزشی",
|
||||
"progress.courseOutline": "طرح درس دوره آموزشی",
|
||||
"progress.courseGrade.label.currentGrade": "نمره کنونی شما",
|
||||
"progress.detailedGrades": "نمرات تفصیلی",
|
||||
"progress.detailedGrades.emptyTable": "اکنون شما هیچ امتیازی از سوال بارمدار ندارید.",
|
||||
"progress.footnotes.title": "پانویس خلاصه نمره",
|
||||
"progress.gradeSummary.grade": "مقطع تحصیلی ",
|
||||
"progress.courseGrade.grades": "نمرهها",
|
||||
"progress.courseGrade.gradesAndCredit": "نمرهها و اعتبارها",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "نکته دامنه نمره",
|
||||
"progress.gradeSummary": "خلاصه نمره",
|
||||
"progress.gradeSummary.limitedAccessExplanation": "شما به تکالیف بارمبندیشده بهعنوان بخشی از مسیر بررسی در این دوره آموزشی، دسترسی محدودی دارید.",
|
||||
"progress.gradeSummary.tooltip.alt": "نکته خلاصه نمره",
|
||||
"progress.gradeSummary.tooltip.body": "وزن تکلیف دوره آموزشی شما بهدست مربی شما تعیین میشود. با ضرب نمره شما در وزن آن نوع تکلیف، نمره وزنی شما محاسبه میشود. نمره وزنی شما چیزی است که برای تعیین قبولی در دوره آموزشی استفاده میشود.",
|
||||
"progress.noAcessToAssignmentType": "شما به نوع تکلیف {assignmentType} دسترسی ندارید",
|
||||
"progress.noAcessToSubsection": "شما به زیربخش {displayName} دسترسی ندارید",
|
||||
"progress.courseGrade.label.passingGrade": "نمره قبولی",
|
||||
"progress.detailedGrades.problemScore.label": "امتیازات سوال:",
|
||||
"progress.detailedGrades.problemScore.toggleButton": "تغییر امتیاز تک تک مسایل برای {subsectionTitle}",
|
||||
"progress.detailedGrades.overridden": "نمرۀ بخش لغو شدهاست.",
|
||||
"progress.score": "امتیاز",
|
||||
"progress.weight": "وزن",
|
||||
"progress.weightedGrade": "درجه وزنی",
|
||||
"progress.weightedGradeSummary": "خلاصه نمره وزنی فعلی شما",
|
||||
"progress.header": "پیشرفت شما",
|
||||
"progress.header.targetUser": "میزان پیشرفت دوره آموزشی برای {username}",
|
||||
"progress.link.studio": "مشاهده نمرهبندی در هنرکده",
|
||||
"progress.relatedLinks.datesCard.description": "نمای برنامه از تاریخ سررسید دوره آموزشی شما و تکالیف آینده.",
|
||||
"progress.relatedLinks.datesCard.link": "تاریخها",
|
||||
"progress.relatedLinks.outlineCard.description": "نمای کلنگر به محتوای دوره آموزشی شما",
|
||||
"progress.relatedLinks.outlineCard.link": "طرح درس دوره آموزشی",
|
||||
"progress.relatedLinks": "پیوندهای مرتبط",
|
||||
"datesBanner.suggestedSchedule": "ما برنامه زمانی پیشنهادی تهیه کردهایم تا به شما کمک کنیم در مسیر خود بمانید. اما نگران نباشید - این برنامه منعطف است تا بتوانید با سرعت موردنظر خود فرابگیرید.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.header": "ارتقا برای قفلگشایی",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.body": "شما در حال بررسی این دوره آموزشی هستید، به این معنی که نمیتوانید در تکالیف بارمبندی شده شرکت کنید. برای تکمیل اینگونه تکالیف بهعنوان بخشی از این دوره آموزشی، میتوانید همین امروز ارتقا دهید.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.button": "اکنون روزآمد کنید",
|
||||
"datesBanner.upgradeToResetBanner.body": "برای اینکه خود را در مسیر موردنظر نگه دارید، میتوانید این برنامه را روزآمد کرده و تکالیف گذشته را به آینده منتقل کنید. نگران نباشید، وقتی تاریخ سررسید خود را تغییر دهید، هیچ یک از پیشرفتهای خود را از دست نخواهید داد.",
|
||||
"datesBanner.upgradeToResetBanner.button": "برای تغییر سررسید از ارتقا استفاده کنید",
|
||||
"datesBanner.resetDatesBanner.header": "به نظر میرسد بر اساس برنامۀ پیشنهادی ما برخی از زمانهای مهم را فراموش کردهاید.",
|
||||
"datesBanner.resetDatesBanner.body": "برای اینکه خود را در مسیر موردنظر نگه دارید، میتوانید این برنامه را روزآمد کرده و تکالیف گذشته را به آینده منتقل کنید. نگران نباشید، وقتی تاریخ سررسید خود را تغییر دهید، هیچ یک از پیشرفتهای خود را از دست نخواهید داد.",
|
||||
"datesBanner.resetDatesBanner.button": "تغییر موعد مقرر",
|
||||
"learn.navigation.course.tabs.label": "منابع آموزشی",
|
||||
"unit.bookmark.button.add.bookmark": "نشانهگذاری صفحه",
|
||||
"unit.bookmark.button.remove.bookmark": "نشانهگذاری شده",
|
||||
"learning.celebration.completed": "شما بهتازگی بخش اول دوره آموزشی خود را تکمیل کردهاید.",
|
||||
"learning.celebration.congrats": "تبریک عرض میکنم!",
|
||||
"learning.celebration.earned": "آن را بدست آوردهاید!",
|
||||
"learning.celebration.emailSubject": "من در راه تکمیل {title} بهصورت برخط با {platform} گام برمیدارم!",
|
||||
"learning.celebration.forward": "ادامه دهید",
|
||||
"learning.celebration.goalMet": "به هدف خود دست یافتید!",
|
||||
"learning.celebration.keepItUp": "همینطور ادامه بده",
|
||||
"learning.celebration.share": "لحظهای را برای جشنگرفتن و به اشتراک گذاری پیشرفت خود اختصاص دهید.",
|
||||
"learning.celebration.social": "من در راه تکمیل {title} بهصورت برخط با {platform} گام برمیدارم. وقت خود را صرف یادگیری چه چیزی میکنید؟",
|
||||
"learning.celebration.goalCongrats": "تبریک میگوییم، شما به هدف یادگیری خود یعنی {nTimes} در هفته دست یافتید.",
|
||||
"learning.celebration.setGoal": "تعیین هدف میتواند به شما در دوره آموزشی {strongText} کمک کند.",
|
||||
"calculator.instructions.button.label": "دستورالعملهای ماشینحساب",
|
||||
"calculator.instructions": "برای اطلاعات دقیق، به {expressions_link} مراجعه کنید.",
|
||||
"calculator.instructions.support.title": "مرکز پشتيباني",
|
||||
"calculator.instructions.useful.tips": "نکات مفید:",
|
||||
"calculator.hint1": "برای شفافسازی عبارات از پرانتز () استفاده کنید. میتوانید از پرانتزهای تودرتو استفاده کنید.",
|
||||
"calculator.hint2": "از فاصله در عبارات استفاده نکنید.",
|
||||
"calculator.hint3": "برای مقادیر ثابت از علامت ضرب استفاده کنید (مثلا: c*5).",
|
||||
"calculator.hint4": "برای پیوستها، شماره و پیوست را بدون فاصله تایپ کنید (مثلاً: 5c).",
|
||||
"calculator.hint5": "برای توابع ، نام عملگر و سپس عبارت را داخل پرانتز تایپ کنید.",
|
||||
"calculator.instruction.table.to.use.heading": "برای استفاده",
|
||||
"calculator.instruction.table.type.heading": "نوع",
|
||||
"calculator.instruction.table.examples.heading": "نمونهها",
|
||||
"calculator.instruction.table.to.use.numbers": "اعداد",
|
||||
"calculator.instruction.table.to.use.numbers.type1": "اعداد صحیح ",
|
||||
"calculator.instruction.table.to.use.numbers.type2": "کسرها",
|
||||
"calculator.instruction.table.to.use.numbers.type3": "اعداد اعشاری",
|
||||
"calculator.instruction.table.to.use.operators": "عملگرها",
|
||||
"calculator.instruction.table.to.use.operators.type1": "(جمع، تفریق، ضرب، تقسیم)",
|
||||
"calculator.instruction.table.to.use.operators.type2": "(پرقدرت برخیز)",
|
||||
"calculator.instruction.table.to.use.operators.type3": "(مقاومتهای موازی)",
|
||||
"calculator.instruction.table.to.use.constants": "موارد ثابت",
|
||||
"calculator.instruction.table.to.use.affixes": "وندها",
|
||||
"calculator.instruction.table.to.use.affixes.type": "علامت درصد (%)",
|
||||
"calculator.instruction.table.to.use.basic.functions": "توابع ابتدایی",
|
||||
"calculator.instruction.table.to.use.trig.functions": "توابع مثلثاتی",
|
||||
"calculator.instruction.table.to.use.scientific.notation": "نماد علمی",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type1": "{exponentSyntax} و شرحدهنده",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type2": "{notationSyntax} یادداشت",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type3": "{notationSyntax} و توان",
|
||||
"calculator.button.label": " ماشین حساب",
|
||||
"calculator.input.field.label": "ورودی ماشینحساب",
|
||||
"calculator.submit.button.label": "محاسبه",
|
||||
"calculator.result.field.label": "نتیجه ماشین حساب",
|
||||
"calculator.result.field.placeholder": "نتیجه",
|
||||
"notes.button.show": "نمایش یادداشتها",
|
||||
"notes.button.hide": "پنهانسازی یادداشتها",
|
||||
"courseExit.catalogSearchSuggestion": "در جستجوی یادگیری بیشتر هستید؟ برای یافتن دورههای آموزشی و برنامههای بیشتر {searchOurCatalogLink} را ملاحظه کنید.",
|
||||
"courseCelebration.certificateBody.available": "\nامروز موفقیت خود را در لینکدین یا در رزومه خود به نمایش بگذارید.\nامکان بارگیری گواهینامه خود را دارید و هر زمان که بخواهید از طریق {dashboardLink} و {profileLink} به آن دسترسی دارید",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate.v2": "این دوره آموزشی در تاریخ {endDate} پایان مییابد. نمرات نهایی و گواهیهای کسب شده طبق برنامه پس از {certAvailableDate} فراهم خواهند بود.",
|
||||
"courseCelebration.certificateBody.unverified": "برای ایجاد گواهی، باید تأیید هویت را تکمیل کنید. اکنون {idVerificationSupportLink}.",
|
||||
"courseCelebration.certificateBody.upgradable": "برای ارتقا اصلا دیر نیست. برای {price} قفل دسترسی به همه تکالیف \nبارمبندیشده در این دوره آموزشی را باز میکنید. پس از تکمیل آن، شما\nگواهی تاییدشده را دریافت خواهید کرد که \nاعتبار ارزشمندی برای بهبود چشم انداز شغلی و پیشرفت شغلی شما\n دارد، یا گواهی خود را در برنامه های کاربردی دانشکده برجسته میکند.",
|
||||
"courseCelebration.upgradeDiscountCodePrompt": "از کد {code} هنگام تسویهحساب برای تخفیف {percent}% استفاده کنید!",
|
||||
"courseCelebration.recommendations.heading": "با این دورههای آموزشی به تقویت مهارتهای خود ادامه دهید!",
|
||||
"courseCelebration.recommendations.label": "دوره آموزشی",
|
||||
"courseCelebration.recommendations.formatting.list_join": "{style, select, punctuation {, } conjunction { {sp}and } other { }}\n ",
|
||||
"courseCelebration.recommendations.browse_catalog": "کاوش دورههای آموزشی بیشتر",
|
||||
"courseCelebration.recommendations.loading_recommendations": "بارگیری توصیهها",
|
||||
"courseCelebration.recommendations.card.schools.label": "مدارس و همکاران",
|
||||
"courseCelebration.dashboardInfo": "میتوانید به این دوره آموزشی و مطالب آن در {dashboardLink} خود دسترسی داشته باشید.",
|
||||
"courseExit.programs.applyForCredit": "درخواست اعتبار",
|
||||
"courseCelebration.certificateHeader.downloadable": "گواهی شما در دسترس است!",
|
||||
"courseCelebration.certificateHeader.notAvailable": "وضعیت نمره و گواهی شما بهزودی اعلام خواهد شد.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "اگر نمره قبولی کسب کرده باشید، گواهی شما بهصورت خودکار صادر میشود.",
|
||||
"courseCelebration.certificateHeader.unverified": "برای دریافت گواهی خود باید تأیید را تکمیل کنید.",
|
||||
"courseCelebration.certificateHeader.requestable": "تبریک می گوییم، شما مجوز دریافت گواهی را دریافت کردید!",
|
||||
"courseCelebration.certificateHeader.upgradable": "برای پیگیری گواهی تاییدشده، ارتقا دهید",
|
||||
"courseCelebration.certificateImage": "نمونه گواهی",
|
||||
"courseCelebration.completedCourseHeader": "دوره آموزشی خود را به پایان رساندید.",
|
||||
"courseCelebration.congratulationsHeader": "تبریک عرض میکنیم!",
|
||||
"courseCelebration.congratulationsImage": "چهار نفر،دستان خود را بهنشانه جشن بالا می برند",
|
||||
"courseExit.courseInProgressDescription": "به نظر میرسد مطالب بیشتری در این دوره آموزشی وجود دارد که در آینده منتشر خواهد شد. از طریق رایانامه، روزآمدسازیها را به اطلاع شما میرسانیم یا دوره آموزشی خود را برای دسترسی به محتوا دوباره بررسی کنید.",
|
||||
"courseExit.courseInProgressHeader": "محتوای بیشتری در آینده در اختیار شما قرار خواهد گرفت!",
|
||||
"courseExit.dashboardLink": "پیشخوان",
|
||||
"courseExit.endOfCourseDescription": "متأسفانه، شما اکنون واجد شرایط دریافت گواهی نیستید. برای دریافت آن باید نمره قبولی دریافت کنید.",
|
||||
"courseExit.endOfCourseHeader": "شما به پایان دوره آموزشی رسیدهاید!",
|
||||
"courseExit.endOfCourseTitle": "پایان دوره آموزشی",
|
||||
"courseExit.idVerificationSupportLink": "درباره تأیید هویت بیشتر بدانید",
|
||||
"courseCelebration.linkedinAddToProfileButton": "افزودن به پرونده لینکداین",
|
||||
"courseExit.programs.microBachelors.learnMore": "درباره نحوه اعمال اعتبار MicroBachelors خود برای دریافت اعتبار بیشتر بدانید.",
|
||||
"courseExit.programs.microMasters.learnMore": "درباره روند اعمال گواهی MicroMasters در مقاطع کارشناسی ارشد بیشتر بدانید.",
|
||||
"courseExit.programs.microMasters.mastersMessage": "اگر علاقه مند به استفاده از گواهی MicroMasters خود برای برنامه کارشناسی ارشد هستید، میتوانید همین امروز شروع کنید!",
|
||||
"learn.sequence.navigation.complete.button": "این دوره آموزشی را تکمیل کنید",
|
||||
"courseExit.nextButton.endOfCourse": "بعدی (پایان دوره آموزشی)",
|
||||
"courseExit.profileLink": "پرونده کاربری",
|
||||
"courseExit.programs.lastCourse": "شما آخرین دوره آموزشی در {title} را گذراندهاید!",
|
||||
"courseCelebration.requestCertificateBodyText": "برای دسترسی به گواهی خود، آن را در زیر درخواست کنید.",
|
||||
"courseCelebration.requestCertificateButton": "درخواست گواهی",
|
||||
"courseExit.searchOurCatalogLink": "جستجوی فهرست ما",
|
||||
"courseCelebration.shareMessage": "اشتراک میزان موفقیت در رسانههای اجتماعی یا رایانامه",
|
||||
"courseExit.social.shareCompletionMessage": "من بهتازگی {title} را با {platform} تکمیل کردم!",
|
||||
"courseExit.upgradeButton": "اکنون روزآمد کنید.",
|
||||
"courseExit.upgradeLink": "الان ارتقا دهید",
|
||||
"courseCelebration.verificationPending": "تأیید هویت شما در حالت انتظار است و گواهی پس از تأیید در دسترس خواهد بود.",
|
||||
"courseExit.verifiedCertificateSupportLink": "درباره گواهیهای تأییدشده بیشتر بدانید",
|
||||
"courseCelebration.verifyIdentityButton": "اکنون شناسه را تأیید کنید",
|
||||
"courseCelebration.viewCertificateButton": "مشاهده گواهی من",
|
||||
"courseExit.viewCourseScheduleButton": "مشاهده برنامه دوره آموزشی",
|
||||
"courseExit.viewCoursesButton": "مشاهده گواهی من",
|
||||
"courseExit.viewGradesButton": "مشاهده نمرات",
|
||||
"courseExit.programCompletion.dashboardMessage": "برای مشاهده وضعیت گواهی خود، بخش برنامهها را در {programLink} خود بررسی کنید.",
|
||||
"courseExit.upgradeFootnote": "دسترسی به این دوره آموزشی و مطالب آن تا {expirationDate} در پیشخوان شما موجود است. برای گسترش دسترسی، {upgradeLink}.",
|
||||
"learn.course.license.allRightsReserved.text": "همه حقوق محفوظ است",
|
||||
"learn.course.license.creativeCommons.terms.preamble": "محتوا مجوز Creative Commons را دارد، با شرایط زیر:",
|
||||
"learn.course.license.creativeCommons.terms.by": "انتساب",
|
||||
"learn.course.license.creativeCommons.terms.nc": "غیرتجاری",
|
||||
"learn.course.license.creativeCommons.terms.nd": "بدون مشتق",
|
||||
"learn.course.license.creativeCommons.terms.sa": "اشتراکگذاری مشابه",
|
||||
"learn.course.license.creativeCommons.terms.zero": "هیچ شرایطی وجود ندارد.",
|
||||
"learn.course.license.creativeCommons.text": "بعضی از حقوق محفوظ است",
|
||||
"learn.breadcrumb.navigation.course.home": "دوره آموزشی",
|
||||
"notification.tray.container": "توالی هشدارها",
|
||||
"notification.open.button": "نمایش توالی هشدارها",
|
||||
"notification.close.button": "بستن توالی هشدارها",
|
||||
"responsive.close.notification": "بازگشت به دوره آموزشی",
|
||||
"notification.tray.title": "اعلانها",
|
||||
"notification.tray.no.message": "اکنون هیچ اعلان جدیدی ندارید.",
|
||||
"learn.contentLock.content.locked": "محتوا قفلشده",
|
||||
"learn.contentLock.complete.prerequisite": "برای دسترسی به این محتوا لازم است پیشنیاز \"{prereqSectionName}\" را تکمیل کنید.",
|
||||
"learn.contentLock.goToSection": "به بخش پیشنیاز بروید",
|
||||
"learn.hiddenAfterDue.gradeAvailable": "اگر این تکلیف را انجام دادهاید، نمره شما در {progressPage} است.",
|
||||
"learn.hiddenAfterDue.header": "موعد ارائۀ این تکلیف گذشته است.",
|
||||
"learn.hiddenAfterDue.description": "چون از موعد مقرر، گذشته است، این تکلیف دیگر در دسترس نیست.",
|
||||
"learn.hiddenAfterDue.progressPage": "صفحه پیشرفت",
|
||||
"learn.honorCode.content": "صداقت و راستی آکادمیک برای {siteName} و موسساتی که دورههای آموزشی و برنامهها را در وبگاه {siteName} ارائه میدهند، مهم است. با کلیک روی «موافقم» در زیر، تأیید میکنم که {link} وبگاه {siteName} را خوانده، درک کرده و از آن تبعیت میکنم.",
|
||||
"learn.honorCode.name": "اصول اخلاقی",
|
||||
"learn.honorCode.cancel": "لغو",
|
||||
"learn.honorCode.agree": "موافقم",
|
||||
"learn.lockPaywall.title": "تکالیف بارمبندی شده قفل شدهاند",
|
||||
"learn.lockPaywall.content": "ارتقا دهید تا به ویژگیهای قفل همچون این مورد، دسترسی پیدا کنید و از دوره آموزشی خود بیشترین بهره را ببرید.",
|
||||
"learn.lockPaywall.content.pastExpiration": "مهلت ارتقای این دوره آموزشی به پایان رسید. برای ارتقا، در جلسه موجود بعدی اقدام به ثبتنام کنید.",
|
||||
"learn.lockPaywall.courseDetails": "مشاهده جزییات دوره آموزشی",
|
||||
"learn.lockPaywall.example.alt": "گواهی نمونه",
|
||||
"learn.lockPaywall.list.intro": "هنگامی که ارتقا میدهید، شما:",
|
||||
"learn.header.h2.placeholder": " ارائهدهندگان ممکن است سرصفحههای سطح 2 دوره آموزشی را در آینده ایجاد کنند.",
|
||||
"learn.course.load.failure": "خطایی در بارگیری این دوره آموزشی رخ داد.",
|
||||
"learn.loading.honor.codk": "در حال بارگیری پیام اصول اخلاقی...",
|
||||
"learn.loading.content.lock": "در حال بارگیری پیام محتوای قفلشده...",
|
||||
"learn.loading.learning.sequence": "در حال بارگیری دنباله یادگیری...",
|
||||
"learn.sequence.no.content": "هیچ محتوایی در اینجا نیست.",
|
||||
"learn.sequence.navigation.next.button": "بعدی",
|
||||
"learn.sequence.navigation.next.up.button": "بعدی: {title}",
|
||||
"learn.sequence.navigation.previous.button": "قبلی",
|
||||
"learn.course.sequence.navigation.mobile.menu": "{current} از {total}",
|
||||
"learn.sequence.share.button": "Share this content",
|
||||
"learn.sequence.share.modal.title": "Title",
|
||||
"learn.sequence.share.modal.body": "Copy the link below to share this content.",
|
||||
"learn.sequence.share.quote": "Here's a fun clip from a class I'm taking on @edXonline.\n",
|
||||
"discussions.sidebar.title": "گفتگوها",
|
||||
"discussions.sidebar.open.button": "نمایش توالی گفتگو",
|
||||
"learn.redirect.interstitial.message": "انتقال...",
|
||||
"learn.loading.error": "خطا: {خطا}",
|
||||
"learning.celebration.emailBody": "وقت خود را صرف یادگیری چه چیزی میکنید؟",
|
||||
"learning.social.shareEmail": "پیشرفت خود را از طریق رایانامه به اشتراک بگذارید.",
|
||||
"learning.social.shareService": "پیشرفت خود را در {service} به اشتراک بگذارید.",
|
||||
"general.altText.close": "بستن",
|
||||
"learning.logistration.register": "ثبتنام",
|
||||
"learning.logistration.login": "ورود به سامانه",
|
||||
"general.signIn.sentenceCase": "ورود به سامانه",
|
||||
"learn.course.tabs.navigation.overflow.menu": "بیشتر...",
|
||||
"learning.offer.screenReaderPrices": "قیمت اصلی: {originalPrice}، قیمت با اعمال تخفیف: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "قیمت اصلی: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "ارتقا برای {pricing}",
|
||||
"learning.upgradeNowButton.buttonText": "برای {pricing} الان ارتقا دهید ",
|
||||
"learning.generic.upgradeNotification.expirationAccessLoss.progress": "از جمله هر پیشرفتی",
|
||||
"learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "مزایای ارتقا",
|
||||
"learning.generic.upgradeNotification.expirationAccessLoss": "شما همه دسترسیها به این دوره آموزشی، {شامل Any Progress} را در تاریخ {date} از دست خواهید داد.",
|
||||
"learning.generic.upgradeNotification.expirationVerifiedCert": "ارتقاء دوره آموزشی این امکان را به شما میدهد تا گواهی تاییدشده را دنبال کنید و ویژگیهای متعددی را فراهم میکند. درباره {benefitsOfUpgrading} بیشتر بدانید.",
|
||||
"learning.generic.upgradeNotification.pastExpiration.content": "مهلت ارتقای این دوره آموزشی به پایان رسید. برای ارتقا، در جلسه موجود بعدی اقدام به ثبتنام کنید.",
|
||||
"learning.generic.upgradeNotification.expirationDays": "{dayCount, number} {dayCount, plural, \none {day}\nother {days}} باقی مانده است",
|
||||
"learning.generic.upgradeNotification.expirationHours": "{hourCount, number} {hourCount, plural, یک {hour} دیگر {hours}} باقی مانده است",
|
||||
"learning.generic.upgradeNotification.expirationMinutes": "کمتر از 1 ساعت باقی مانده است",
|
||||
"learning.generic.upgradeNotification.expiration": "دسترسی به دوره آموزشی در {date} انقضا خواهد یافت",
|
||||
"learning.generic.upgradeNotification.pastExpiration.banner": "مهلت ارتقا در {date} به پایان رسید",
|
||||
"learning.generic.upgradeNotification.firstTimeLearnerDiscount": "{percentage}% تخفیف اولین_بار",
|
||||
"learning.generic.upgradeNotification.accessExpiration": "امروز دوره آموزشی خود را ارتقا دهید",
|
||||
"learning.generic.upgradeNotification.accessExpirationUrgent": "پایان دسترسی به دوره آموزشی",
|
||||
"learning.generic.upgradeNotification.accessExpirationPast": "پایان دسترسی به دوره آموزشی",
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "دنبال یک گواهی تأییدشده باشید",
|
||||
"learning.generic.upgradeNotification.code": "هنگام تسویه حساب از کد {code} استفاده کنید",
|
||||
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "گواهی تاییدشده",
|
||||
"learning.generic.upsell.verifiedCertBullet": "یک {verifiedCertLink} تکمیلی برای نمایش در رزومه خود کسب کنید",
|
||||
"learning.generic.upsell.unlockGradedBullet.gradedAssignments": "تکالیف بارمدار",
|
||||
"learning.generic.upsell.unlockGradedBullet": "قفل دسترسی خود را به همه فعالیتهای دوره آموزشی، همچون {gradedAssignmentsInBoldText} باز کنید",
|
||||
"learning.generic.upsell.fullAccessBullet.fullAccess": "دسترسی کامل",
|
||||
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} به محتوا و منابع دوره آموزشی، حتی پس از پایان دوره",
|
||||
"learning.generic.upsell.supportMissionBullet.mission": "ماموریت",
|
||||
"learning.generic.upsell.supportMissionBullet": "از {missionInBoldText} ما در {siteName} پشتیبانی کنید",
|
||||
"masquerade-widget.userName.error.generic": "خطایی رخ داده است؛ لطفا دوباره تلاش کنید.",
|
||||
"masquerade-widget.userName.input.placeholder": "نام کاربری یا نشانی رایانامه",
|
||||
"masquerade-widget.userName.input.label": "خود را بهعنوان همین کاربر نشان دهید",
|
||||
"tours.abandonTour.launchTourCheckpoint.body": "احساس میکنید گم شدهاید؟ تور را هر زمان که خواستید راه اندازی کنید تا نکاتی سریع برای استفاده حداکثری از این تجربه در اختیار شما بگذارد.",
|
||||
"tours.sequenceNavigationCheckpoint.body": "نوار بالای دوره آموزشی به شما این امکان را میدهد بهراحتی به بخشهای مختلف بپرید و به شما نشان میدهد که چه چیزی در راه است.",
|
||||
"tours.existingUserTour.launchTourCheckpoint.body": "اخیراً چند ویژگی جدید به دوره آموزشی افزودهایم. مایلید نگاهی بیندازید؟ برای کسب اطلاعات بیشتر به تور بروید.",
|
||||
"tours.button.dismiss": "نادیده بگیر",
|
||||
"tours.button.next": "بعدی",
|
||||
"tours.button.okay": "بسیار خوب",
|
||||
"tours.button.beginTour": "آغاز تور",
|
||||
"tours.button.launchTour": "راهاندازی تور",
|
||||
"tours.newUserModal.body": "بیایید یک تور سریع از {siteName} داشته باشیم تا بتوانید بیشترین بهره را از دوره آموزشی خود ببرید.",
|
||||
"tours.newUserModal.title.welcome": "خوش آمدید به ",
|
||||
"tours.button.skipForNow": "فعلا بگذرید",
|
||||
"tours.datesCheckpoint.body": "تاریخهای مهم به شما کمک میکنند در مسیر خود بمانید.",
|
||||
"tours.datesCheckpoint.title": "جلوتر از تاریخهای مهم باشید",
|
||||
"tours.outlineCheckpoint.body": "شما امکان کاوش بخشهای مختلف این دوره آموزشی را با استفاده از طرح درس زیر دارید.",
|
||||
"tours.outlineCheckpoint.title": "دوره آموزشی را بگذرانید!",
|
||||
"tours.tabNavigationCheckpoint.body": "از این زبانهها میتوان برای دسترسی به سایر منابع آموزشی مانند میزان پیشرفت، برنامه آموزشی و سایر موارد استفاده کرد.",
|
||||
"tours.tabNavigationCheckpoint.title": "منابع آموزشی بیشتر برای این دوره آموزشی ",
|
||||
"tours.upgradeCheckpoint.body": "برای دریافت گواهی تلاش کنید و به منابع آموزشی دوره دسترسی کامل داشته باشید. برای این کار، اکنون ارتقا دهید!",
|
||||
"tours.upgradeCheckpoint.title": "دوره آموزشی خود را بگشایید",
|
||||
"tours.weeklyGoalsCheckpoint.body": "تعیین یک هدف باعث میشود احتمالا تمایل بیشتری برای تکمیل دوره آموزشی خود داشته باشید.",
|
||||
"tours.weeklyGoalsCheckpoint.title": "یک هدف برای دوره آموزشی تعیین کنید",
|
||||
"tours.newUserModal.title": "دوره آموزشی {welcome} {siteName}!",
|
||||
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
|
||||
"learning.effortEstimation.activities": "{activityCount, plural, one {# قعالیت} other {# فعالیتها}}",
|
||||
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# دقیقه} other {# دقیقه}}",
|
||||
"learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# دقیقه} other {# دقیقه}}",
|
||||
"learning.streakCelebration.congratulations": "تبریک میگوییم!",
|
||||
"learning.streakCelebration.body": "به همین شکل ادامه دهید، شما در حال پیشرفت هستید!",
|
||||
"learning.streakCelebration.button": "همینطور ادامه بده",
|
||||
"learning.streakCelebration.buttonSrOnly": "بستن مودال و ادامه",
|
||||
"learning.streakCelebration.buttonAA759": "ادامه دوره آموزشی",
|
||||
"learning.streakCelebration.header": "خط روز",
|
||||
"learning.streakCelebration.factoidABoldedSection": "احتمال قبولی در دوره آموزشی آنها 20 برابر بیشتر است",
|
||||
"learning.streakCelebration.factoidBBoldedSection": "بهطور متوسط 5 برابر بیشتر محتوای دوره آموزشی را تکمیل کنید",
|
||||
"learning.streakCelebration.streakDiscountMessage": "وقتی این دوره آموزشی را فقط برای مدت محدودی ارتقا میدهید، {percent}% تخفیف دریافت کردهاید.",
|
||||
"learning.streakcelebration.factoida": "کاربرانی که {streak_length} روز متوالی {bolded_section} را آموزشی میبینند نسبت به کاربرانی که چنین آموزشی ندارند.",
|
||||
"learning.streakcelebration.factoidb": "کاربرانی که {streak_length} روز متوالی {bolded_section} را یاد میگیرند در مقابل کاربرانی که نمیآموزند.",
|
||||
"learning.streakCelebration.streakCelebrationCouponEndDateMessage": "در {date} پایان مییابد.",
|
||||
"learning.loading.failure": "خطایی در بارگیری این دوره آموزشی رخ داد.",
|
||||
"learning.loading": "در حال بارگیری صفحه دوره آموزشی…"
|
||||
}
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Ouverture de l'intégration : {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Examiner les instructions et la configuration système requise",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Intégration en retard",
|
||||
"learning.outline.sequence-due": "{description} échéance {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre certificat maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
|
||||
|
||||
@@ -114,9 +114,10 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Ouverture de l'intégration : {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Examiner les instructions et la configuration système requise",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Intégration en retard",
|
||||
"learning.outline.sequence-due": "{description} échéance {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} échéance {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre certificat maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
|
||||
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre attestation maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
|
||||
"progress.certificateStatus.notPassingHeader": "État de l'attestation",
|
||||
"progress.certificateStatus.notPassingBody": "Pour être admissible à une attestation, vous devez avoir la note de passage.",
|
||||
@@ -141,7 +142,7 @@
|
||||
"progress.completion.donut.label": "complété",
|
||||
"progress.completion.body": "Cela représente la part du contenu du cours que vous avez terminé. Notez que certains contenus peuvent ne pas encore être publiés.",
|
||||
"progress.completion.tooltip.locked": "Contenu que vous avez terminé.",
|
||||
"progress.completion.header": "Achèvement du cours",
|
||||
"progress.completion.header": "Complétion du cours",
|
||||
"progress.completion.tooltip": "Contenu auquel vous avez accès et que vous n'avez pas terminé.",
|
||||
"progress.completion.tooltip.complete": "Contenu verrouillé et disponible uniquement pour ceux qui effectuent une mise à niveau.",
|
||||
"progress.completion.donut.percentComplete": "Vous avez terminé {percent}% du contenu de ce cours.",
|
||||
@@ -303,7 +304,7 @@
|
||||
"courseExit.nextButton.endOfCourse": "Suivant (fin du cours)",
|
||||
"courseExit.profileLink": "Profil",
|
||||
"courseExit.programs.lastCourse": "Vous avez terminé le dernier cours de {title}!",
|
||||
"courseCelebration.requestCertificateBodyText": "Pour accéder à votre certificat, demandez-le ci-dessous.",
|
||||
"courseCelebration.requestCertificateBodyText": "Pour accéder à votre attestation, demandez-la ci-dessous.",
|
||||
"courseCelebration.requestCertificateButton": "Demander une attestation",
|
||||
"courseExit.searchOurCatalogLink": "Rechercher dans notre catalogue",
|
||||
"courseCelebration.shareMessage": "Partagez votre succès sur les réseaux sociaux ou par courriel.",
|
||||
@@ -398,7 +399,7 @@
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "Obtenir une attestation vérifiée",
|
||||
"learning.generic.upgradeNotification.code": "Utilisez le code {code} lors du paiement",
|
||||
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "attestation vérifiée",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Obtenez un {verifiedCertLink} d'achèvement pour le mettre en valeur sur votre CV",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Obtenez une {verifiedCertLink} de complétion pour la mettre en valeur sur votre CV",
|
||||
"learning.generic.upsell.unlockGradedBullet.gradedAssignments": "évaluations notées",
|
||||
"learning.generic.upsell.unlockGradedBullet": "Débloquer votre accès à toutes les activités du cours, incluant {gradedAssignmentsInBoldText}",
|
||||
"learning.generic.upsell.fullAccessBullet.fullAccess": "Accès complet",
|
||||
@@ -413,23 +414,23 @@
|
||||
"tours.existingUserTour.launchTourCheckpoint.body": "Nous avons récemment ajouté des nouvelles fonctions à l'expérience de cours. Vous avez besoin d'aide à les trouver? Prenez un tour guidé pour en apprendre plus.",
|
||||
"tours.button.dismiss": "Rejeter",
|
||||
"tours.button.next": "Suivant",
|
||||
"tours.button.okay": "Okay",
|
||||
"tours.button.okay": "D'accord",
|
||||
"tours.button.beginTour": "Commencer la visite guidée",
|
||||
"tours.button.launchTour": "Lancer la visite guidée",
|
||||
"tours.newUserModal.body": "Faisons un tour rapide de {siteName} afin que vous puissiez tirer le meilleur parti de votre cours.",
|
||||
"tours.newUserModal.title.welcome": "Bienvenu à votre",
|
||||
"tours.newUserModal.title.welcome": "Bienvenue à votre",
|
||||
"tours.button.skipForNow": "Ignorer pour l'instant",
|
||||
"tours.datesCheckpoint.body": "Dates importantes afin de vous maintenir sur la bonne voie.",
|
||||
"tours.datesCheckpoint.title": "Restez au courant des dates importantes",
|
||||
"tours.outlineCheckpoint.body": "Vous pouvez explorer les sections du cours en utilisant la table des matières ci-dessous.",
|
||||
"tours.outlineCheckpoint.title": "Suivez le cours!",
|
||||
"tours.tabNavigationCheckpoint.body": "Ces onglets peuvent être utilisés pour accéder aux autres ressources de cours, tel que votre progression, le plan de cours, etc.",
|
||||
"tours.tabNavigationCheckpoint.body": "Ces onglets peuvent être utilisés pour accéder aux autres ressources de cours, telles que votre progression, le plan de cours, etc.",
|
||||
"tours.tabNavigationCheckpoint.title": "Ressources de cours additionnelles",
|
||||
"tours.upgradeCheckpoint.body": "Travaillez vers une attestation et obtenez un accès complet au matériel de cours. Mettre à niveau maintenant!",
|
||||
"tours.upgradeCheckpoint.title": "Débloquez votre cours",
|
||||
"tours.weeklyGoalsCheckpoint.body": "Paramétrer un objectif encourage à compléter votre cours.",
|
||||
"tours.weeklyGoalsCheckpoint.title": "Paramétrer un objectif de cours",
|
||||
"tours.newUserModal.title": "{welcome} {siteName} au cours!",
|
||||
"tours.newUserModal.title": "{welcome} cours sur {siteName} !",
|
||||
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
|
||||
"learning.effortEstimation.activities": "{activityCount, plural, one {# activité} many {# activités} other {# activités}}",
|
||||
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} many {# min} other {# min}}",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user