Compare commits
131 Commits
mikix/mfe-
...
KristinAok
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8882026a01 | ||
|
|
c6578d4e2e | ||
|
|
fe4680646e | ||
|
|
c09ba48615 | ||
|
|
c46da1dc34 | ||
|
|
9ca5c61088 | ||
|
|
71db431b97 | ||
|
|
b741525bfc | ||
|
|
a17e2a1a15 | ||
|
|
ea02b2f70f | ||
|
|
5fa33e4015 | ||
|
|
0394118608 | ||
|
|
c6df8cdbb5 | ||
|
|
1fbd9d4645 | ||
|
|
42a3f6b244 | ||
|
|
6ca4c99c0d | ||
|
|
5deac01615 | ||
|
|
1160353ab9 | ||
|
|
ca8cfda9b9 | ||
|
|
3d9cb20e33 | ||
|
|
37f32fddf2 | ||
|
|
b5689a7997 | ||
|
|
c88ea31c20 | ||
|
|
9de77c282d | ||
|
|
1b995d2510 | ||
|
|
66300caf30 | ||
|
|
569b628961 | ||
|
|
29391f7741 | ||
|
|
43eb58974a | ||
|
|
15782609c3 | ||
|
|
f2fc950678 | ||
|
|
42445d884f | ||
|
|
a5ea7431fc | ||
|
|
df29cd0f9a | ||
|
|
4898487a82 | ||
|
|
6588153e4c | ||
|
|
a568c5f2fc | ||
|
|
054afc0475 | ||
|
|
58e8de2c22 | ||
|
|
2b00cecd19 | ||
|
|
6f2281c1a4 | ||
|
|
5538b48ebb | ||
|
|
847cdfa0bd | ||
|
|
38db0ebfe1 | ||
|
|
7b57b06ed5 | ||
|
|
9c2190980e | ||
|
|
b4c83a38aa | ||
|
|
5efc22220f | ||
|
|
0ba9ed7d31 | ||
|
|
a32a58019d | ||
|
|
367c8ad0df | ||
|
|
58f1634c63 | ||
|
|
2092a5d8d1 | ||
|
|
1308d1e90b | ||
|
|
ea93aea4dd | ||
|
|
3b4dcfefaf | ||
|
|
e05428e01d | ||
|
|
24de9d7add | ||
|
|
4e136d9c55 | ||
|
|
296607fb76 | ||
|
|
544e11b628 | ||
|
|
75b195bdc0 | ||
|
|
d4a4cd24ec | ||
|
|
07042d9908 | ||
|
|
2d1a13ab0a | ||
|
|
7fde146edd | ||
|
|
149ca245fd | ||
|
|
5f0968e348 | ||
|
|
20935e7860 | ||
|
|
40ea41996f | ||
|
|
f0fab488a5 | ||
|
|
7f2df8b886 | ||
|
|
9b33f20eaa | ||
|
|
7242583f13 | ||
|
|
229692255f | ||
|
|
96a5753b1b | ||
|
|
56ea6d46d4 | ||
|
|
d12e93d80a | ||
|
|
63c86701de | ||
|
|
b99910357b | ||
|
|
7b45c8b6fa | ||
|
|
f2d7e119a5 | ||
|
|
4baf78c79e | ||
|
|
d517f94c49 | ||
|
|
b4bedfe3f0 | ||
|
|
8c41e182a2 | ||
|
|
43ff07af3e | ||
|
|
aeca68fd56 | ||
|
|
29a24aa62e | ||
|
|
4be725b4c2 | ||
|
|
fae2396977 | ||
|
|
276f2a516a | ||
|
|
01ba277425 | ||
|
|
c592753182 | ||
|
|
64f374855b | ||
|
|
a8348e1568 | ||
|
|
6a3ad1d659 | ||
|
|
2f0933be6e | ||
|
|
4be3b8a56f | ||
|
|
2075a0b3dd | ||
|
|
52750ef769 | ||
|
|
e2b00d6684 | ||
|
|
6003865840 | ||
|
|
30a487ec13 | ||
|
|
174be4adc7 | ||
|
|
388b9dfe59 | ||
|
|
c667e29492 | ||
|
|
e4ec845bd4 | ||
|
|
e96d885114 | ||
|
|
be4375dd7c | ||
|
|
a4d651a77a | ||
|
|
3703e8d81d | ||
|
|
c5e480456a | ||
|
|
d57dd66dd2 | ||
|
|
eb70d3733d | ||
|
|
fcda48513a | ||
|
|
abac174e2e | ||
|
|
377f780e85 | ||
|
|
057b431818 | ||
|
|
e4a883e335 | ||
|
|
984926d97c | ||
|
|
9f81897fd2 | ||
|
|
457dc4b279 | ||
|
|
3b2f91cd32 | ||
|
|
270c177a83 | ||
|
|
19f318679f | ||
|
|
d38c07a206 | ||
|
|
3b2bbbdbc4 | ||
|
|
832107f084 | ||
|
|
b23a6330f1 | ||
|
|
8970352cdd |
5
.env
5
.env
@@ -1,3 +1,6 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
@@ -32,4 +35,4 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2000'
|
||||
@@ -32,4 +35,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'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='test'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2000'
|
||||
|
||||
4
.github/workflows/validate.yml
vendored
4
.github/workflows/validate.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
||||
- 12
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ coverage
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
logs
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint
|
||||
96
README.rst
96
README.rst
@@ -1,23 +1,21 @@
|
||||
|Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|codecov| |license|
|
||||
|
||||
frontend-app-learning
|
||||
=========================
|
||||
|
||||
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
React app for edX learning.
|
||||
This is the Learning MFE (micro-frontend application), which renders all
|
||||
learner-facing course pages (like the course outline, the progress page,
|
||||
actual course content, etc).
|
||||
|
||||
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://coveralls.io/github/edx/frontend-app-learning
|
||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
|
||||
|
||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
Development
|
||||
-----------
|
||||
@@ -25,22 +23,10 @@ Development
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
- Start devstack
|
||||
- Log in (http://localhost:18000/login)
|
||||
|
||||
Start the development server
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
In this project, install requirements and start the development server by running:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
npm install
|
||||
npm start # The server will run on port 1995
|
||||
|
||||
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
- Run ``make dev.up.lms``
|
||||
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
|
||||
Local module development
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -67,3 +53,59 @@ file (which is git-ignored) that defines where to find your local modules, for i
|
||||
};
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
Deployment
|
||||
----------
|
||||
|
||||
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
|
||||
edX Developer Guide's section on
|
||||
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
|
||||
|
||||
Environment Variables
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This MFE is configured via environment variables supplied at build time.
|
||||
All micro-frontends have a shared set of required environment variables,
|
||||
as documented in the Open edX Developer Guide under
|
||||
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
|
||||
|
||||
The learning micro-frontend also supports the following additional variables:
|
||||
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN
|
||||
This value is passed as the ``utm_campaign`` parameter for social-share
|
||||
links when celebrating learning milestones in the course. Optional.
|
||||
|
||||
Example: ``milestone``
|
||||
|
||||
SUPPORT_URL_CALCULATOR_MATH
|
||||
A link that explains how to use the in-course calculator. You can use the
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
|
||||
|
||||
SUPPORT_URL_ID_VERIFICATION
|
||||
A link that explains how to verify your ID. Shown in contexts where you need
|
||||
to verify yourself to earn a certificate. The example link below is probably too
|
||||
edx.org-specific to use for your own site.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity
|
||||
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE
|
||||
A link that explains what a verified certificate is. You can use the
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
Optional.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
|
||||
|
||||
TWITTER_HASHTAG
|
||||
This value is used in the Twitter social-share link when celebrating learning
|
||||
milestones in the course. Will prefill the suggested post with this hashtag.
|
||||
Optional.
|
||||
|
||||
Example: ``brandedhashtag``
|
||||
|
||||
TWITTER_URL
|
||||
A link to your Twitter account. The Twitter social-share link won't appear
|
||||
unless this is set. Optional.
|
||||
|
||||
Example: https://twitter.com/edXOnline
|
||||
|
||||
@@ -88,3 +88,6 @@ And more like:
|
||||
```
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/Being_Social/Teams
|
||||
```
|
||||
|
||||
_This further work has been expanded upon in
|
||||
[ADR #9: Courseware URL shortening](./0009-courseware-url-shortening.md)._
|
||||
|
||||
58
docs/decisions/0009-courseware-url-shortening.md
Normal file
58
docs/decisions/0009-courseware-url-shortening.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Courseware URL shortening
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
_This updates some of the content in [ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
|
||||
|
||||
## Context
|
||||
|
||||
The current URL is not human-readable. The URL is composed of the UsageKeys for the current sequence and unit. We can't make UsageKeys themselves more readable because they're tied to student state.
|
||||
|
||||
This is what the URLs currently look like:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/block-v1:edX+DemoX.1+2T2019+type@sequential+block@e0a61b3d5a2046949e76d12cac5df493/block-v1:edX+DemoX.1+2T2019+type@vertical+block@52dbad5a28e140f291a476f92ce11996
|
||||
|
||||
```
|
||||
|
||||
After exploring different URL patterns and possible redundancies in the current URL format, the following key points were noticed. The course, run, and organization are stated in every portion of the URL. We also do not need the URL to tell us the type of block since it has been determined that all URLs will follow the path` /course/:courseId/:sequenceId/:unitId`.
|
||||
|
||||
## Decision
|
||||
|
||||
The courseware URL will format to the following structure:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/c/:courseId/:sequenceHash/:unitHash/:sectionSlug/:sequenceSlug/:unitSlug/
|
||||
|
||||
```
|
||||
|
||||
Example URL:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/c/course-v1:edX+DemoX.1+2T2019/YmxvY2/njuRCq/optional-example-problem-types/stem-problems/code-grader
|
||||
|
||||
```
|
||||
|
||||
The fields definition and requirements ar as follows:
|
||||
|
||||
* :courseId (required) - same as the previous `courseId`.
|
||||
* :sequenceHash (required) - a `blake2b` version of the `sequenceId`'s `urlsafe_b64encode` .
|
||||
* :unitHash (required) - a `blake2b` version of the `unitId`'s `urlsafe_b64encode`.
|
||||
* :sectionSlug (optional) - `display_name` of the current sequence's parent section.
|
||||
* :sequenceSlug (optional) - `display_name` of the current sequence.
|
||||
* :unitSlug (optional) - `display_name` of the current unit
|
||||
|
||||
Partial paths will update with the required parameters as dicussed in [ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md). The `sequenceHash` and `unitHash` will shorten their respective ids using `hashlib.blake2b` with `digest_size` of 6 bytes. `Blake2b` will reduce the length of the id so the encoded version can also be short. Hashing will be handled by `blake2b` because it is the fastest hashing function in the `hashlib` library. The hash will be generated and mapped in LMS. The slugs based on `display_name` are optional because not all blocks have an associated `display_name` attributes, most likely to occur in OLX imports. The `display_name` will be pulled from the current section, sequence, and unit attribute, and if there is not an attribute `display`, the url will use the attribute `display_name_with_default`. The `display_name` will be formatted safely for a url using Django's [slugify](https://docs.djangoproject.com/en/3.2/ref/utils/#django.utils.text.slugify). Slugify allows unicode identifiers in the slug. If the slugs are omitted, it will redirect to the canonical version without the slugs.
|
||||
|
||||
## Consequences
|
||||
|
||||
If old URLs are not properly routed then the content and those links will no longer be accessible to the user. The old URLs could include, but not limited to, bookmarks and exams.
|
||||
|
||||
## Further work
|
||||
|
||||
At some point, we may decide to further extend the URL shortening to the entire platform. At the moment, the hashes for the sequences and units are generated when the sequences and units are being called. In the future, it would be better if the hashes would be generated and stored when the sequences and units are originally created. This would require `learning_sequences` to include a class for unit storage, which is not being stored at the moment.
|
||||
15803
package-lock.json
generated
15803
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
68
package.json
68
package.json
@@ -16,15 +16,11 @@
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"prepare": "husky install",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
@@ -36,49 +32,51 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.1.5",
|
||||
"@edx/frontend-enterprise": "4.2.3",
|
||||
"@edx/frontend-lib-special-exams": "1.9.0",
|
||||
"@edx/frontend-platform": "1.11.0",
|
||||
"@edx/paragon": "15.2.2",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
||||
"@fortawesome/react-fontawesome": "0.1.14",
|
||||
"@reduxjs/toolkit": "1.3.6",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"@edx/frontend-component-footer": "10.1.6",
|
||||
"@edx/frontend-enterprise-utils": "0.1.7",
|
||||
"@edx/frontend-lib-special-exams": "1.12.0",
|
||||
"@edx/frontend-platform": "1.12.3",
|
||||
"@edx/paragon": "16.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@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.15",
|
||||
"@pact-foundation/pact": "9.16.0",
|
||||
"@reduxjs/toolkit": "1.6.1",
|
||||
"classnames": "2.3.1",
|
||||
"core-js": "3.16.1",
|
||||
"js-cookie": "2.2.1",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.13.1",
|
||||
"react": "17.0.2",
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-helmet": "6.0.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.4",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-share": "4.2.1",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"react-share": "4.4.0",
|
||||
"redux": "4.1.1",
|
||||
"regenerator-runtime": "0.13.9",
|
||||
"reselect": "4.0.0",
|
||||
"truncate-html": "1.0.3"
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "5.5.5",
|
||||
"@edx/frontend-build": "8.0.0",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.10.1",
|
||||
"@testing-library/jest-dom": "5.14.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.0.17",
|
||||
"axios-mock-adapter": "1.18.2",
|
||||
"codecov": "3.8.2",
|
||||
"es-check": "5.1.4",
|
||||
"@testing-library/user-event": "12.8.3",
|
||||
"axios-mock-adapter": "1.19.0",
|
||||
"codecov": "3.8.3",
|
||||
"es-check": "5.2.4",
|
||||
"glob": "7.1.7",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"husky": "7.0.1",
|
||||
"jest": "27.0.6",
|
||||
"jest-chain": "1.1.5",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.0.1"
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
|
||||
import AccessExpirationAlertMasquerade from './AccessExpirationAlertMasquerade';
|
||||
@@ -100,7 +100,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
|
||||
function AccessExpirationAlertMMP2P({ payload }) {
|
||||
@@ -52,7 +52,7 @@ function AccessExpirationAlertMMP2P({ payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<span className="font-weight-bold">
|
||||
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
function AccessExpirationAlertMasquerade({ payload }) {
|
||||
const {
|
||||
@@ -26,7 +26,7 @@ function AccessExpirationAlertMasquerade({ payload }) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.expired"
|
||||
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { Info, WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
import { useEnrollClickHandler } from './hooks';
|
||||
import useEnrollClickHandler from './clickHook';
|
||||
|
||||
function EnrollmentAlert({ intl, payload }) {
|
||||
const {
|
||||
@@ -30,27 +30,29 @@ function EnrollmentAlert({ intl, payload }) {
|
||||
);
|
||||
|
||||
let text = intl.formatMessage(messages.alert);
|
||||
let type = ALERT_TYPES.ERROR;
|
||||
let type = 'warning';
|
||||
let icon = WarningFilled;
|
||||
if (isStaff) {
|
||||
text = intl.formatMessage(messages.staffAlert);
|
||||
type = ALERT_TYPES.INFO;
|
||||
type = 'info';
|
||||
icon = Info;
|
||||
} else if (extraText) {
|
||||
text = `${text} ${extraText}`;
|
||||
}
|
||||
|
||||
const button = canEnroll && (
|
||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
|
||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top mx-1" size="sm" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages.enrollNowSentence)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type={type}>
|
||||
{text}
|
||||
{' '}
|
||||
{button}
|
||||
{' '}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
<Alert variant={type} icon={icon}>
|
||||
<div className="d-flex">
|
||||
{text}
|
||||
{button}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
35
src/alerts/enrollment-alert/clickHook.js
Normal file
35
src/alerts/enrollment-alert/clickHook.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useContext, useState, useCallback } from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
// Separated into its own file to avoid a circular dependency inside this directory
|
||||
|
||||
function useEnrollClickHandler(courseId, orgId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
sendTrackEvent('edx.bi.user.course-home.enrollment', {
|
||||
org_key: orgId,
|
||||
courserun_key: courseId,
|
||||
});
|
||||
global.location.reload();
|
||||
});
|
||||
}, [courseId]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
export default useEnrollClickHandler;
|
||||
@@ -1,15 +1,12 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, {
|
||||
useContext, useState, useCallback, useMemo,
|
||||
useContext, useMemo,
|
||||
} from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
@@ -40,28 +37,3 @@ export function useEnrollmentAlert(courseId) {
|
||||
|
||||
return { clientEnrollmentAlert: EnrollmentAlert };
|
||||
}
|
||||
|
||||
export function useEnrollClickHandler(courseId, orgId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
sendTrackEvent('edx.bi.user.course-home.enrollment', {
|
||||
org_key: orgId,
|
||||
courserun_key: courseId,
|
||||
});
|
||||
global.location.reload();
|
||||
});
|
||||
}, [courseId]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { Alert } from '../../generic/user-messages';
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
function LogistrationAlert({ intl }) {
|
||||
@@ -29,7 +29,7 @@ function LogistrationAlert({ intl }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type="error">
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
<FormattedMessage
|
||||
id="learning.logistration.alert"
|
||||
description="Prompts the user to sign in or register to see course content."
|
||||
|
||||
@@ -18,9 +18,7 @@ function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username
|
||||
);
|
||||
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
|
||||
dashboardMenuItem = (
|
||||
<Dropdown.Item
|
||||
href={enterpriseLearnerPortalLink.href}
|
||||
>
|
||||
<Dropdown.Item href={enterpriseLearnerPortalLink.href}>
|
||||
{enterpriseLearnerPortalLink.content}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
@@ -62,13 +60,17 @@ function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username
|
||||
}
|
||||
|
||||
AuthenticatedUserDropdown.propTypes = {
|
||||
enterpriseLearnerPortalLink: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
enterpriseLearnerPortalLink: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
AuthenticatedUserDropdown.defaultProps = {
|
||||
enterpriseLearnerPortalLink: '',
|
||||
enterpriseLearnerPortalLink: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(AuthenticatedUserDropdown);
|
||||
|
||||
@@ -11,7 +11,7 @@ function CourseTabsNavigation({
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-fluid">
|
||||
<div className="container-xl">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
|
||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
@@ -61,7 +61,7 @@ function Header({
|
||||
return (
|
||||
<header className="course-header">
|
||||
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
|
||||
<div className="container-fluid py-2 d-flex align-items-center">
|
||||
<div className="container-xl py-2 d-flex align-items-center">
|
||||
{headerLogo}
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
|
||||
@@ -10,4 +10,13 @@ Factory.define('courseHomeMetadata')
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
can_load_courseware: false,
|
||||
course_access: {
|
||||
additional_context_user_message: null,
|
||||
developer_message: null,
|
||||
error_code: null,
|
||||
has_access: true,
|
||||
user_fragment: null,
|
||||
user_message: null,
|
||||
},
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
});
|
||||
|
||||
@@ -66,4 +66,5 @@ Factory.define('outlineTabData')
|
||||
handouts_html: '<ul><li>Handout 1</li></ul>',
|
||||
offer: null,
|
||||
welcome_message_html: '<p>Welcome to this course!</p>',
|
||||
mfe_short_url_is_active: true,
|
||||
});
|
||||
|
||||
@@ -24,10 +24,12 @@ Factory.define('progressTabData')
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 0,
|
||||
num_points_possible: 1,
|
||||
num_points_possible: 3,
|
||||
percent_graded: 0.0,
|
||||
problem_scores: [{ earned: 0, possible: 1 }, { earned: 0, possible: 1 }, { earned: 0, possible: 1 }],
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
@@ -44,6 +46,7 @@ Factory.define('progressTabData')
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 1.0,
|
||||
problem_scores: [{ earned: 1, possible: 1 }],
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
|
||||
@@ -8,6 +8,7 @@ Factory.define('upgradeNotificationData')
|
||||
.option('accessExpiration', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('upsellPageName', 'test')
|
||||
.attr('verifiedMode', ['host'], (host) => ({
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
|
||||
@@ -5,7 +5,6 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -17,12 +16,21 @@ Object {
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
@@ -30,6 +38,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -302,7 +311,6 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -314,12 +322,21 @@ Object {
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
@@ -327,6 +344,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -380,8 +398,6 @@ Object {
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"effortActivities": undefined,
|
||||
"effortTime": undefined,
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionIds": Array [
|
||||
@@ -394,8 +410,6 @@ Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"effortActivities": 2,
|
||||
"effortTime": 15,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
@@ -409,8 +423,9 @@ Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
"effortActivities": undefined,
|
||||
"effortTime": undefined,
|
||||
"effortActivities": 2,
|
||||
"effortTime": 15,
|
||||
"hash_key": "abcdabcd1",
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
|
||||
@@ -449,6 +464,7 @@ Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"enrollmentMode": undefined,
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
@@ -458,7 +474,9 @@ Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"shortLinkFeatureFlag": true,
|
||||
"timeOffsetMillis": 0,
|
||||
"userHasPassingGrade": undefined,
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
@@ -482,7 +500,6 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -494,12 +511,21 @@ Object {
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
@@ -507,6 +533,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -564,6 +591,8 @@ Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"end": "3027-03-31T00:00:00Z",
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
@@ -590,9 +619,24 @@ Object {
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"learnerHasAccess": true,
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 1,
|
||||
"numPointsPossible": 3,
|
||||
"percentGraded": 0,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
],
|
||||
"showCorrectness": "always",
|
||||
"showGrades": true,
|
||||
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
|
||||
@@ -609,6 +653,12 @@ Object {
|
||||
"numPointsEarned": 1,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 1,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 1,
|
||||
"possible": 1,
|
||||
},
|
||||
],
|
||||
"showCorrectness": "always",
|
||||
"showGrades": true,
|
||||
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
|
||||
|
||||
@@ -111,8 +111,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
models.courses[block.id] = {
|
||||
effortActivities: block.effort_activities,
|
||||
effortTime: block.effort_time,
|
||||
id: courseId,
|
||||
title: block.display_name,
|
||||
sectionIds: block.children || [],
|
||||
@@ -123,8 +121,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
case 'chapter':
|
||||
models.sections[block.id] = {
|
||||
complete: block.complete,
|
||||
effortActivities: block.effort_activities,
|
||||
effortTime: block.effort_time,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
resumeBlock: block.resume_block,
|
||||
@@ -148,6 +144,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
// link to the MFE ourselves).
|
||||
showLink: !!block.legacy_web_url,
|
||||
title: block.display_name,
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -182,26 +179,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
return models;
|
||||
}
|
||||
|
||||
function processTabDataErrorRedirect(error) {
|
||||
const { httpErrorResponseData, httpErrorStatus } = error && error.customAttributes;
|
||||
|
||||
if (httpErrorStatus === 403) {
|
||||
// Currently, only 403 errors contain redirect content
|
||||
try {
|
||||
const { redirect } = JSON.parse(httpErrorResponseData);
|
||||
if (redirect) {
|
||||
global.location.replace(redirect);
|
||||
return true;
|
||||
}
|
||||
} catch (exc) {
|
||||
// ignore any json parse errors, might be an actual 403 without redirect json content
|
||||
}
|
||||
}
|
||||
|
||||
// Did not do any redirecting
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
@@ -222,18 +199,13 @@ export async function getDatesTabData(courseId) {
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
// 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 {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -272,29 +244,44 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
|
||||
|
||||
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
|
||||
|
||||
camelCasedData.gradesFeatureIsPartiallyLocked = false;
|
||||
if (camelCasedData.gradesFeatureIsFullyLocked) {
|
||||
camelCasedData.sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
// If something is eligible to be gated by content type gating and would show up on the progress page
|
||||
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
|
||||
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
|
||||
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
|
||||
// since the learner has access to some (but not all) content that would normally be locked
|
||||
if (subsection.learnerHasAccess) {
|
||||
camelCasedData.gradesFeatureIsPartiallyLocked = true;
|
||||
camelCasedData.gradesFeatureIsFullyLocked = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
// 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 {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProctoringInfoData(courseId, username) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
if (username) {
|
||||
url += `&username=${encodeURIComponent(username)}`;
|
||||
}
|
||||
@@ -337,12 +324,8 @@ export async function getOutlineTabData(courseId) {
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -361,14 +344,17 @@ export async function getOutlineTabData(courseId) {
|
||||
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const enrollmentMode = data.enrollment_mode;
|
||||
const handoutsHtml = data.handouts_html;
|
||||
const hasScheduledContent = data.has_scheduled_content;
|
||||
const hasEnded = data.has_ended;
|
||||
const offer = camelCaseObject(data.offer);
|
||||
const resumeCourse = camelCaseObject(data.resume_course);
|
||||
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
||||
const userHasPassingGrade = data.user_has_passing_grade;
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
const shortLinkFeatureFlag = data.mfe_short_url_is_active;
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
@@ -380,14 +366,17 @@ export async function getOutlineTabData(courseId) {
|
||||
datesBannerInfo,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
enrollmentMode,
|
||||
handoutsHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
offer,
|
||||
resumeCourse,
|
||||
timeOffsetMillis, // This should move to a global time correction reference
|
||||
userHasPassingGrade,
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
shortLinkFeatureFlag,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,26 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'course-home',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
gradesFeatureIsLocked: false,
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
},
|
||||
reducers: {
|
||||
fetchTabDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = DENIED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
fetchTabRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
@@ -25,10 +33,6 @@ const slice = createSlice({
|
||||
state.targetUserId = payload.targetUserId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
setCallToActionToast: (state, { payload }) => {
|
||||
const {
|
||||
header,
|
||||
@@ -39,18 +43,15 @@ const slice = createSlice({
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
setGradesFeatureStatus: (state, { payload }) => {
|
||||
state.gradesFeatureIsLocked = payload.gradesFeatureIsLocked;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
setCallToActionToast,
|
||||
setGradesFeatureStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
@@ -49,11 +50,6 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
logError(courseHomeCourseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedTabData && tabDataResult.value === null) {
|
||||
// null tab data indicates that we have redirected elsewhere - just exit and don't visibly stop loading
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchedTabData) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
@@ -66,7 +62,10 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
|
||||
@@ -23,19 +23,24 @@ jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('DatesTab', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
let component;
|
||||
|
||||
const store = initializeStore();
|
||||
const component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/c/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
let courseMetadata = Factory.build('courseHomeMetadata');
|
||||
@@ -74,10 +79,9 @@ describe('DatesTab', () => {
|
||||
|
||||
describe('when receiving a full set of dates data', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
history.push(`/c/${courseId}/dates`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
@@ -142,9 +146,8 @@ describe('DatesTab', () => {
|
||||
|
||||
describe('Suggested schedule messaging', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
setMetadata({ is_self_paced: true, is_enrolled: true });
|
||||
history.push(`/course/${courseId}/dates`);
|
||||
history.push(`/c/${courseId}/dates`);
|
||||
});
|
||||
|
||||
it('renders SuggestedScheduleHeader', async () => {
|
||||
@@ -295,4 +298,55 @@ describe('DatesTab', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when receiving an access denied error', () => {
|
||||
// These tests could go into any particular tab, as they all go through the same flow. But dates tab works.
|
||||
|
||||
async function renderDenied(errorCode) {
|
||||
setMetadata({
|
||||
course_access: {
|
||||
has_access: false,
|
||||
error_code: errorCode,
|
||||
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
|
||||
},
|
||||
});
|
||||
render(component);
|
||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/c/${courseId}/dates`); // so tab can pull course id from url
|
||||
});
|
||||
|
||||
it('redirects to course survey for a survey_required error code', async () => {
|
||||
await renderDenied('survey_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('redirects to dashboard for an unfulfilled_milestones error code', async () => {
|
||||
await renderDenied('unfulfilled_milestones');
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
|
||||
});
|
||||
|
||||
it('redirects to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
|
||||
await renderDenied('audit_expired');
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
|
||||
});
|
||||
|
||||
it('redirects to the dashboard with a notlive start date for a course_not_started error code', async () => {
|
||||
await renderDenied('course_not_started');
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?notlive=2/5/2013'); // date from factory
|
||||
});
|
||||
|
||||
it('redirects to the home page when unauthenticated', async () => {
|
||||
await renderDenied('authentication_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('redirects to the home page when unenrolled', async () => {
|
||||
await renderDenied('enrollment_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,11 +224,12 @@ function OutlineTab({ intl }) {
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
shouldDisplayBorder
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('Outline Tab', () => {
|
||||
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
@@ -156,7 +156,7 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`);
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/c/${courseId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,8 +485,8 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const alert = await screen.findByText('Welcome to Demonstration Course');
|
||||
expect(alert.parentElement).toHaveAttribute('role', 'alert');
|
||||
const alert = await screen.findByTestId('private-course-alert');
|
||||
expect(alert).toHaveAttribute('role', 'alert');
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
|
||||
@@ -495,8 +495,8 @@ describe('Outline Tab', () => {
|
||||
it('displays alert for unenrolled user', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const alert = await screen.findByText('Welcome to Demonstration Course');
|
||||
expect(alert.parentElement).toHaveAttribute('role', 'alert');
|
||||
const alert = await screen.findByTestId('private-course-alert');
|
||||
expect(alert).toHaveAttribute('role', 'alert');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -693,6 +693,40 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
});
|
||||
it('renders non passing grade', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {},
|
||||
user_has_passing_grade: false,
|
||||
has_ended: true,
|
||||
enrollment_mode: 'verified',
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
screen.getAllByText('You are not eligible for a certificate');
|
||||
expect(screen.queryByText('You are not eligible for a certificate')).toBeInTheDocument();
|
||||
});
|
||||
it('tracks request cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/f
|
||||
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import EffortEstimate from '../../shared/effort-estimate';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
@@ -29,6 +28,7 @@ function Section({
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
shortLinkFeatureFlag,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -40,6 +40,28 @@ function Section({
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
}, []);
|
||||
let sequenceLinks;
|
||||
if (shortLinkFeatureFlag) {
|
||||
sequenceLinks = sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequences[sequenceId].hash_key}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
sequenceLinks = sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const sectionTitle = (
|
||||
<div className="row w-100 m-0">
|
||||
@@ -67,7 +89,6 @@ function Section({
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={section} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -98,15 +119,7 @@ function Section({
|
||||
)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
{sequenceLinks}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
|
||||
@@ -46,7 +46,7 @@ function SequenceLink({
|
||||
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
|
||||
const coursewareUrl = (
|
||||
canLoadCourseware
|
||||
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
|
||||
? <Link to={`/c/${courseId}/${id}`}>{title}</Link>
|
||||
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
|
||||
);
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
@@ -36,6 +36,8 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
} = payload;
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
@@ -118,6 +120,24 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
return alertProps;
|
||||
};
|
||||
|
||||
const renderNotPassingCourseEnded = () => {
|
||||
const progressTab = tabs.find(tab => tab.slug === 'progress');
|
||||
const progressLink = progressTab && progressTab.url;
|
||||
|
||||
const alertProps = {
|
||||
header: intl.formatMessage(certMessages.certStatusNotPassingHeader),
|
||||
buttonMessage: intl.formatMessage(certMessages.certStatusNotPassingButton),
|
||||
body: intl.formatMessage(certStatusMessages.notPassingBody),
|
||||
buttonVisible: true,
|
||||
buttonLink: progressLink,
|
||||
buttonAction: () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_view_grades_button.clicked');
|
||||
},
|
||||
};
|
||||
|
||||
return alertProps;
|
||||
};
|
||||
|
||||
let alertProps = {};
|
||||
switch (certStatus) {
|
||||
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
|
||||
@@ -129,6 +149,9 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
alertProps = renderNotIDVerifiedStatus();
|
||||
break;
|
||||
default:
|
||||
if (notPassingCourseEnded) {
|
||||
alertProps = renderNotPassingCourseEnded();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -184,6 +207,12 @@ CertificateStatusAlert.propTypes = {
|
||||
isWebCert: PropTypes.bool,
|
||||
userTimezone: PropTypes.string,
|
||||
org: PropTypes.string,
|
||||
notPassingCourseEnded: PropTypes.bool,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
tab_id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
})),
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,9 +21,19 @@ function verifyCertStatusType(status) {
|
||||
}
|
||||
|
||||
function useCertificateStatusAlert(courseId) {
|
||||
const VERIFIED_MODES = {
|
||||
PROFESSIONAL: 'professional',
|
||||
VERIFIED: 'verified',
|
||||
NO_ID_PROFESSIONAL_MODE: 'no-id-professional',
|
||||
CREDIT_MODE: 'credit',
|
||||
MASTERS: 'masters',
|
||||
EXECUTIVE_EDUCATION: 'executive-education',
|
||||
};
|
||||
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
tabs,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
@@ -32,6 +42,9 @@ function useCertificateStatusAlert(courseId) {
|
||||
userTimezone,
|
||||
},
|
||||
certData,
|
||||
hasEnded,
|
||||
userHasPassingGrade,
|
||||
enrollmentMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
@@ -42,7 +55,11 @@ function useCertificateStatusAlert(courseId) {
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isWebCert = downloadUrl === null;
|
||||
|
||||
const isVerifiedEnrollmentMode = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
|
||||
);
|
||||
let certURL = '';
|
||||
if (certWebViewUrl) {
|
||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
@@ -52,8 +69,17 @@ function useCertificateStatusAlert(courseId) {
|
||||
}
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
// Only show if there is a known cert status that we want provide status on.
|
||||
// Only show if:
|
||||
// - there is a known cert status that we want provide status on.
|
||||
// - Or the course has ended and the learner does not have a passing grade.
|
||||
const isVisible = isEnrolled && hasAlertingCertStatus;
|
||||
const notPassingCourseEnded = (
|
||||
isEnrolled
|
||||
&& isVerifiedEnrollmentMode
|
||||
&& !hasAlertingCertStatus
|
||||
&& hasEnded
|
||||
&& !userHasPassingGrade
|
||||
);
|
||||
const payload = {
|
||||
certificateAvailableDate,
|
||||
certURL,
|
||||
@@ -63,9 +89,11 @@ function useCertificateStatusAlert(courseId) {
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
useAlert(isVisible || notPassingCourseEnded, {
|
||||
code: 'clientCertificateStatusAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
|
||||
@@ -11,6 +11,14 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Congratulations! Your certificate is ready.',
|
||||
description: 'Header alerting the user that their certificate is ready.',
|
||||
},
|
||||
certStatusNotPassingHeader: {
|
||||
id: 'cert.alert.notPassing.header',
|
||||
defaultMessage: 'You are not eligible for a certificate',
|
||||
},
|
||||
certStatusNotPassingButton: {
|
||||
id: 'cert.alert.notPassing.button',
|
||||
defaultMessage: 'View grades',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
@@ -78,7 +78,7 @@ function CourseEndAlert({ payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>{msg}</strong><br />
|
||||
{description}
|
||||
</Alert>
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
@@ -30,7 +30,7 @@ function CourseStartAlert({ payload }) {
|
||||
const delta = new Date(startDate) - new Date();
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.short"
|
||||
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
|
||||
@@ -55,7 +55,7 @@ function CourseStartAlert({ payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
|
||||
@@ -3,15 +3,15 @@ import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { Button, Hyperlink } from '@edx/paragon';
|
||||
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import { Alert } from '../../../../generic/user-messages';
|
||||
import enrollmentMessages from '../../../../alerts/enrollment-alert/messages';
|
||||
import genericMessages from '../../../../generic/messages';
|
||||
import messages from './messages';
|
||||
import outlineMessages from '../../messages';
|
||||
import { useEnrollClickHandler } from '../../../../alerts/enrollment-alert/hooks';
|
||||
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
function PrivateCourseAlert({ intl, payload }) {
|
||||
@@ -32,12 +32,13 @@ function PrivateCourseAlert({ intl, payload }) {
|
||||
intl.formatMessage(enrollmentMessages.success),
|
||||
);
|
||||
|
||||
const enrollNow = (
|
||||
const enrollNowButton = (
|
||||
<Button
|
||||
disabled={loading}
|
||||
variant="link"
|
||||
className="p-0 border-0 align-top"
|
||||
className="p-0 border-0 align-top mr-1"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
size="sm"
|
||||
onClick={enrollClickHandler}
|
||||
>
|
||||
{intl.formatMessage(enrollmentMessages.enrollNowInline)}
|
||||
@@ -63,7 +64,7 @@ function PrivateCourseAlert({ intl, payload }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type="welcome">
|
||||
<Alert variant="light" data-testid="private-course-alert">
|
||||
{anonymousUser && (
|
||||
<>
|
||||
<p className="font-weight-bold">
|
||||
@@ -84,15 +85,11 @@ function PrivateCourseAlert({ intl, payload }) {
|
||||
<>
|
||||
<p className="font-weight-bold">{intl.formatMessage(outlineMessages.welcomeTo)} {title}</p>
|
||||
{canEnroll && (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="learning.privateCourse.canEnroll"
|
||||
description="Prompts the user to enroll in the course to see course content."
|
||||
defaultMessage="{enrollNow} to access the full course."
|
||||
values={{ enrollNow }}
|
||||
/>
|
||||
<div className="d-flex">
|
||||
{enrollNowButton}
|
||||
{intl.formatMessage(messages.toAccess)}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{!canEnroll && (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
enroll: {
|
||||
toAccess: {
|
||||
id: 'alert.enroll',
|
||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
||||
description: 'Text instructing the learner to enroll in the course in order to see course content.',
|
||||
defaultMessage: ' to access the full course.',
|
||||
description: 'Text instructing the learner to enroll in the course in order to see course content. The full string'
|
||||
+ 'would say "Enroll now to access the full course", where "Enroll now" is a button.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
||||
import camelCase from 'lodash.camelcase';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
@@ -131,7 +130,7 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
|
||||
{isNotYetSubmitted(status) && (
|
||||
<>
|
||||
{!isNotYetReleased(releaseDate) && (
|
||||
<Button variant="primary" block href={`${getConfig().LMS_BASE_URL}${link}`}>
|
||||
<Button variant="primary" block href={link}>
|
||||
{readableStatus === readableStatuses.otherCourseApproved && (
|
||||
<>
|
||||
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
|
||||
|
||||
@@ -2,14 +2,13 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, TransitionReplace } from '@edx/paragon';
|
||||
import { Alert, Button, TransitionReplace } from '@edx/paragon';
|
||||
import truncate from 'truncate-html';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import LmsHtmlFragment from '../LmsHtmlFragment';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { Alert } from '../../../generic/user-messages';
|
||||
import { dismissWelcomeMessage } from '../../data/thunks';
|
||||
|
||||
function WelcomeMessage({ courseId, intl }) {
|
||||
@@ -27,52 +26,47 @@ function WelcomeMessage({ courseId, intl }) {
|
||||
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
|
||||
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
display && (
|
||||
<Alert
|
||||
type="welcome"
|
||||
dismissible
|
||||
onDismiss={() => {
|
||||
setDisplay(false);
|
||||
dispatch(dismissWelcomeMessage(courseId));
|
||||
}}
|
||||
footer={messageCanBeShortened && (
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button
|
||||
block
|
||||
onClick={() => setShowShortMessage(!showShortMessage)}
|
||||
variant="outline-primary"
|
||||
>
|
||||
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
|
||||
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Alert
|
||||
data-testid="alert-container-welcome"
|
||||
variant="light"
|
||||
stacked
|
||||
dismissible
|
||||
show={display}
|
||||
onClose={() => {
|
||||
setDisplay(false);
|
||||
dispatch(dismissWelcomeMessage(courseId));
|
||||
}}
|
||||
actions={messageCanBeShortened ? [
|
||||
<Button
|
||||
onClick={() => setShowShortMessage(!showShortMessage)}
|
||||
variant="outline-primary"
|
||||
>
|
||||
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
|
||||
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
|
||||
</Button>,
|
||||
] : []}
|
||||
>
|
||||
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
|
||||
{showShortMessage ? (
|
||||
<LmsHtmlFragment
|
||||
className="inline-link"
|
||||
data-testid="short-welcome-message-iframe"
|
||||
key="short-html"
|
||||
html={shortWelcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
) : (
|
||||
<LmsHtmlFragment
|
||||
className="inline-link"
|
||||
data-testid="long-welcome-message-iframe"
|
||||
key="full-html"
|
||||
html={welcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
|
||||
{showShortMessage ? (
|
||||
<LmsHtmlFragment
|
||||
className="inline-link"
|
||||
data-testid="short-welcome-message-iframe"
|
||||
key="short-html"
|
||||
html={shortWelcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
) : (
|
||||
<LmsHtmlFragment
|
||||
className="inline-link"
|
||||
data-testid="long-welcome-message-iframe"
|
||||
key="full-html"
|
||||
html={welcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
)}
|
||||
</TransitionReplace>
|
||||
</Alert>
|
||||
)
|
||||
</TransitionReplace>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import CertificateStatus from './certificate-status/CertificateStatus';
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -10,7 +10,6 @@ import GradeSummary from './grades/grade-summary/GradeSummary';
|
||||
import ProgressHeader from './ProgressHeader';
|
||||
import RelatedLinks from './related-links/RelatedLinks';
|
||||
|
||||
import { setGradesFeatureStatus } from '../data/slice';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function ProgressTab() {
|
||||
@@ -19,18 +18,10 @@ function ProgressTab() {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const gradesFeatureIsLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = gradesFeatureIsLocked ? 'locked-overlay' : '';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(setGradesFeatureStatus({ gradesFeatureIsLocked }));
|
||||
}, []);
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
@@ -50,7 +41,7 @@ function ProgressTab() {
|
||||
<CertificateStatus />
|
||||
</OnMobile>
|
||||
<CourseGrade />
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsLocked}>
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
|
||||
<GradeSummary />
|
||||
<DetailedGrades />
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,7 @@ describe('Progress Tab', () => {
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
@@ -176,6 +177,7 @@ describe('Progress Tab', () => {
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 8,
|
||||
num_points_possible: 10,
|
||||
@@ -252,6 +254,26 @@ describe('Progress Tab', () => {
|
||||
sku: 'ABCD1234',
|
||||
upgrade_url: 'edx.org/upgrade',
|
||||
},
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: false,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 8,
|
||||
num_points_possible: 10,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('locked feature')).toBeInTheDocument();
|
||||
@@ -259,7 +281,7 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getAllByRole('link', 'Unlock now')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('sends event on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
|
||||
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
setTabData({
|
||||
completion_summary: {
|
||||
@@ -275,6 +297,26 @@ describe('Progress Tab', () => {
|
||||
sku: 'ABCD1234',
|
||||
upgrade_url: 'edx.org/upgrade',
|
||||
},
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: false,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 8,
|
||||
num_points_possible: 10,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('locked feature')).toBeInTheDocument();
|
||||
@@ -283,12 +325,20 @@ describe('Progress Tab', () => {
|
||||
const upgradeButton = screen.getAllByRole('link', 'Unlock now')[0];
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.grades_upgrade.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'progress_locked',
|
||||
linkType: 'button',
|
||||
pageName: 'progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders locked feature preview with no upgrade button when user has locked content but cannot upgrade', async () => {
|
||||
@@ -298,6 +348,26 @@ describe('Progress Tab', () => {
|
||||
incomplete_count: 1,
|
||||
locked_count: 1,
|
||||
},
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: false,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('locked feature')).toBeInTheDocument();
|
||||
@@ -309,6 +379,62 @@ describe('Progress Tab', () => {
|
||||
expect(screen.queryByText('locked feature')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders limited feature preview with upgrade button when user has access to some content that would typically be locked', async () => {
|
||||
setTabData({
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
incomplete_count: 1,
|
||||
locked_count: 1,
|
||||
},
|
||||
verified_mode: {
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
upgrade_url: 'edx.org/upgrade',
|
||||
},
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@123456',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: false,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 8,
|
||||
num_points_possible: 10,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
display_name: 'Second subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('limited feature')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unlock to work towards a certificate.')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('You have limited access to graded assignments as part of the audit track in this course.')).toHaveLength(2);
|
||||
|
||||
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders correct current grade tooltip when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
@@ -321,6 +447,7 @@ describe('Progress Tab', () => {
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
@@ -337,6 +464,7 @@ describe('Progress Tab', () => {
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
@@ -531,6 +659,7 @@ describe('Progress Tab', () => {
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
@@ -554,8 +683,8 @@ describe('Progress Tab', () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('link', { name: 'First subsection' }));
|
||||
expect(screen.getByRole('link', { name: 'Second subsection' }));
|
||||
expect(screen.getByText('First subsection'));
|
||||
expect(screen.getByText('Second subsection'));
|
||||
});
|
||||
|
||||
it('sends event on click of subsection link', async () => {
|
||||
@@ -591,6 +720,20 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders individual problem score drawer', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
const problemScoreDrawerToggle = screen.getByRole('button', { name: 'Toggle individual problem scores for First subsection' });
|
||||
expect(problemScoreDrawerToggle).toBeInTheDocument();
|
||||
|
||||
// Open the problem score drawer
|
||||
fireEvent.click(problemScoreDrawerToggle);
|
||||
expect(screen.getByText('Problem Scores:')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('0/1')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('render message when section scores are not populated', async () => {
|
||||
setTabData({
|
||||
section_scores: [],
|
||||
@@ -958,13 +1101,21 @@ describe('Progress Tab', () => {
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'audit_passing',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'progress_certificate',
|
||||
linkType: 'button',
|
||||
pageName: 'progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays nothing if audit only', async () => {
|
||||
|
||||
@@ -46,6 +46,12 @@ function CertificateStatus({ intl }) {
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
@@ -77,17 +83,21 @@ function CertificateStatus({ intl }) {
|
||||
const idVerificationSupportLink = <IdVerificationSupportLink />;
|
||||
const profileLink = <ProfileLink />;
|
||||
|
||||
// Some learners have a valid ("downloadable") certificate without being in a passing
|
||||
// state (e.g. learners who have been added to a course's allowlist), so we need to
|
||||
// skip grade validation for these learners
|
||||
const certIsDownloadable = certStatus === 'downloadable';
|
||||
if (mode === COURSE_EXIT_MODES.disabled) {
|
||||
certEventName = 'certificate_status_disabled';
|
||||
} else if (mode === COURSE_EXIT_MODES.nonPassing) {
|
||||
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) {
|
||||
certCase = 'notPassing';
|
||||
certEventName = 'not_passing';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
} else if (mode === COURSE_EXIT_MODES.inProgress) {
|
||||
} else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) {
|
||||
certCase = 'inProgress';
|
||||
certEventName = 'has_scheduled_content';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||
} else if (mode === COURSE_EXIT_MODES.celebration || certIsDownloadable) {
|
||||
switch (certStatus) {
|
||||
case 'requesting':
|
||||
certCase = 'requestable';
|
||||
@@ -198,6 +208,15 @@ function CertificateStatus({ intl }) {
|
||||
is_staff: administrator,
|
||||
certificate_status_variant: certEventName,
|
||||
});
|
||||
if (certCase === 'upgrade') {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'progress_certificate',
|
||||
linkType: 'button',
|
||||
pageName: 'progress',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,10 +13,11 @@ import messages from '../messages';
|
||||
function CourseGrade({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradesFeatureIsPartiallyLocked,
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
@@ -24,12 +25,12 @@ function CourseGrade({ intl }) {
|
||||
|
||||
const passingGrade = Number((Math.min(...Object.values(gradeRange)) * 100).toFixed(0));
|
||||
|
||||
const applyLockedOverlay = gradesFeatureIsLocked ? 'locked-overlay' : '';
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<section className="text-dark-700 my-4 rounded shadow-sm">
|
||||
{gradesFeatureIsLocked && <CourseGradeHeader />}
|
||||
<div className={applyLockedOverlay} aria-hidden={gradesFeatureIsLocked}>
|
||||
{(gradesFeatureIsFullyLocked || gradesFeatureIsPartiallyLocked) && <CourseGradeHeader />}
|
||||
<div className={applyLockedOverlay} aria-hidden={gradesFeatureIsFullyLocked}>
|
||||
<div className="row w-100 m-0 p-4">
|
||||
<div className="col-12 col-sm-6 p-0 pr-sm-2">
|
||||
<h2>{intl.formatMessage(messages.grades)}</h2>
|
||||
|
||||
@@ -19,8 +19,14 @@ function CourseGradeHeader({ intl }) {
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
verifiedMode,
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const logUpgradeButtonClick = () => {
|
||||
sendTrackEvent('edx.ui.lms.course_progress.grades_upgrade.clicked', {
|
||||
@@ -28,7 +34,22 @@ function CourseGradeHeader({ intl }) {
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'progress_locked',
|
||||
linkType: 'button',
|
||||
pageName: 'progress',
|
||||
});
|
||||
};
|
||||
let previewText;
|
||||
if (verifiedMode) {
|
||||
previewText = gradesFeatureIsFullyLocked
|
||||
? intl.formatMessage(messages.courseGradePreviewUnlockCertificateBody)
|
||||
: intl.formatMessage(messages.courseGradePartialPreviewUnlockCertificateBody);
|
||||
} else {
|
||||
previewText = intl.formatMessage(messages.courseGradePreviewUpgradeDeadlinePassedBody);
|
||||
}
|
||||
return (
|
||||
<div className="row w-100 m-0 p-4 rounded-top bg-primary-500 text-white">
|
||||
<div className={`col-12 ${verifiedMode ? 'col-md-9' : ''} p-0`}>
|
||||
@@ -40,13 +61,14 @@ function CourseGradeHeader({ intl }) {
|
||||
<span aria-hidden="true">
|
||||
{intl.formatMessage(messages.courseGradePreviewHeaderAriaHidden)}
|
||||
</span>
|
||||
{intl.formatMessage(messages.courseGradePreviewHeader)}
|
||||
{gradesFeatureIsFullyLocked
|
||||
? intl.formatMessage(messages.courseGradePreviewHeaderLocked)
|
||||
: intl.formatMessage(messages.courseGradePreviewHeaderLimited)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row w-100 m-0 p-0 justify-content-end">
|
||||
<div className="col-11 px-2 p-sm-0 small">
|
||||
{verifiedMode ? intl.formatMessage(messages.courseGradePreviewUnlockCertificateBody)
|
||||
: intl.formatMessage(messages.courseGradePreviewUpgradeDeadlinePassedBody)}
|
||||
{previewText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import messages from '../messages';
|
||||
function GradeBar({ intl, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
@@ -20,11 +19,12 @@ function GradeBar({ intl, passingGrade }) {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
|
||||
const lockedTooltipClassName = gradesFeatureIsLocked ? 'locked-overlay' : '';
|
||||
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<div className="col-12 col-sm-6 align-self-center">
|
||||
|
||||
@@ -14,10 +14,10 @@ import messages from '../messages';
|
||||
function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
@@ -68,7 +68,7 @@ function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
disabled={gradesFeatureIsLocked}
|
||||
disabled={gradesFeatureIsFullyLocked}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { Blocked } from '@edx/paragon/icons';
|
||||
import { Icon, Hyperlink } from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import DetailedGradesTable from './DetailedGradesTable';
|
||||
@@ -16,12 +17,13 @@ function DetailedGrades({ intl }) {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradesFeatureIsPartiallyLocked,
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
@@ -40,7 +42,7 @@ function DetailedGrades({ intl }) {
|
||||
className="muted-link inline-link"
|
||||
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
|
||||
onClick={logOutlineLinkClick}
|
||||
tabIndex={gradesFeatureIsLocked ? '-1' : '0'}
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
>
|
||||
{intl.formatMessage(messages.courseOutline)}
|
||||
</Hyperlink>
|
||||
@@ -49,8 +51,14 @@ function DetailedGrades({ intl }) {
|
||||
return (
|
||||
<section className="text-dark-700">
|
||||
<h3 className="h4 mb-3">{intl.formatMessage(messages.detailedGrades)}</h3>
|
||||
{gradesFeatureIsPartiallyLocked && (
|
||||
<div className="mb-3 small ml-0 d-inline">
|
||||
<Icon className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" />
|
||||
{intl.formatMessage(messages.gradeSummaryLimitedAccessExplanation)}
|
||||
</div>
|
||||
)}
|
||||
{hasSectionScores && (
|
||||
<DetailedGradesTable sectionScores={sectionScores} />
|
||||
<DetailedGradesTable />
|
||||
)}
|
||||
{!hasSectionScores && (
|
||||
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { DataTable } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import messages from '../messages';
|
||||
import SubsectionTitleCell from './SubsectionTitleCell';
|
||||
|
||||
function DetailedGradesTable({ intl, sectionScores }) {
|
||||
function DetailedGradesTable({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const logSubsectionClicked = (blockKey) => {
|
||||
sendTrackEvent('edx.ui.lms.course_progress.detailed_grades_assignment.clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
assignment_block_key: blockKey,
|
||||
});
|
||||
};
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
return (
|
||||
sectionScores.map((chapter) => {
|
||||
const subsectionScores = chapter.subsections.filter(
|
||||
@@ -40,24 +30,10 @@ function DetailedGradesTable({ intl, sectionScores }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailedGradesData = subsectionScores.map((subsection) => {
|
||||
const title = (
|
||||
<a
|
||||
href={subsection.url}
|
||||
className="muted-link small"
|
||||
onClick={() => {
|
||||
logSubsectionClicked(subsection.blockKey);
|
||||
}}
|
||||
tabIndex={gradesFeatureIsLocked ? '-1' : '0'}
|
||||
>
|
||||
{subsection.displayName}
|
||||
</a>
|
||||
);
|
||||
return {
|
||||
subsectionTitle: title,
|
||||
score: `${subsection.numPointsEarned}/${subsection.numPointsPossible}`,
|
||||
};
|
||||
});
|
||||
const detailedGradesData = subsectionScores.map((subsection) => ({
|
||||
subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
|
||||
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}/{subsection.numPointsPossible}</span>,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="my-3" key={`${chapter.displayName}-grades-table`}>
|
||||
@@ -89,21 +65,6 @@ function DetailedGradesTable({ intl, sectionScores }) {
|
||||
|
||||
DetailedGradesTable.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
sectionScores: PropTypes.arrayOf(PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
subsections: PropTypes.arrayOf(PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
numPointsEarned: PropTypes.number.isRequired,
|
||||
numPointsPossible: PropTypes.number.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})),
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
DetailedGradesTable.defaultProps = {
|
||||
sectionScores: {
|
||||
subsections: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default injectIntl(DetailedGradesTable);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function ProblemScoreDrawer({ intl, problemScores, subsection }) {
|
||||
return (
|
||||
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
|
||||
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
|
||||
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
|
||||
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
|
||||
{problemScores.map(problemScore => (
|
||||
<li className="ml-3">{problemScore.earned}/{problemScore.possible}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
ProblemScoreDrawer.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
problemScores: PropTypes.arrayOf(PropTypes.shape({
|
||||
earned: PropTypes.number.isRequired,
|
||||
possible: PropTypes.number.isRequired,
|
||||
})).isRequired,
|
||||
subsection: PropTypes.shape({ learnerHasAccess: PropTypes.bool }).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProblemScoreDrawer);
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, Icon, Row } from '@edx/paragon';
|
||||
import { ArrowDropDown, ArrowDropUp, Blocked } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import ProblemScoreDrawer from './ProblemScoreDrawer';
|
||||
|
||||
function SubsectionTitleCell({ intl, subsection }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const {
|
||||
blockKey,
|
||||
displayName,
|
||||
problemScores,
|
||||
url,
|
||||
} = subsection;
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const logSubsectionClicked = () => {
|
||||
sendTrackEvent('edx.ui.lms.course_progress.detailed_grades_assignment.clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
assignment_block_key: blockKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced>
|
||||
<Row className="w-100 m-0">
|
||||
<Collapsible.Trigger
|
||||
className="mr-1 position-absolute"
|
||||
aria-label={intl.formatMessage(messages.problemScoreToggleAltText, { subsectionTitle: displayName })}
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
>
|
||||
<Collapsible.Visible whenClosed><Icon src={ArrowDropDown} /></Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen><Icon src={ArrowDropUp} /></Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<span className="small d-inline ml-4 pl-1">
|
||||
{gradesFeatureIsFullyLocked || subsection.learnerHasAccess ? '' : <Icon id={`detailedGradesBlockedIcon${subsection.blockKey}`} aria-label={intl.formatMessage(messages.noAcessToSubsection, { displayName })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" />}
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
className="muted-link small"
|
||||
onClick={logSubsectionClicked}
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
aria-labelledby={`detailedGradesBlockedIcon${subsection.blockKey}`}
|
||||
>
|
||||
{displayName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="greyed-out small">{displayName}</span>
|
||||
)}
|
||||
</span>
|
||||
</Row>
|
||||
<Collapsible.Body>
|
||||
<ProblemScoreDrawer problemScores={problemScores} subsection={subsection} />
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
}
|
||||
|
||||
SubsectionTitleCell.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
subsection: PropTypes.shape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SubsectionTitleCell);
|
||||
@@ -1,14 +1,28 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Blocked } from '@edx/paragon/icons';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import messages from '../messages';
|
||||
|
||||
function AssignmentTypeCell({ assignmentType, footnoteMarker, footnoteId }) {
|
||||
function AssignmentTypeCell({
|
||||
intl, assignmentType, footnoteMarker, footnoteId, locked,
|
||||
}) {
|
||||
const {
|
||||
gradesFeatureIsLocked,
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const lockedIcon = locked ? <Icon id={`assignmentTypeBlockedIcon${assignmentType}`} aria-label={intl.formatMessage(messages.noAcessToAssignmentType, { assignmentType })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" /> : '';
|
||||
|
||||
return (
|
||||
<div className="small">
|
||||
{assignmentType}
|
||||
<span className="d-inline-flex">{lockedIcon}{assignmentType}</span>
|
||||
{footnoteId && footnoteMarker && (
|
||||
<sup>
|
||||
<a
|
||||
@@ -16,7 +30,8 @@ function AssignmentTypeCell({ assignmentType, footnoteMarker, footnoteId }) {
|
||||
className="muted-link"
|
||||
href={`#${footnoteId}-footnote`}
|
||||
aria-describedby="grade-summary-footnote-label"
|
||||
tabIndex={gradesFeatureIsLocked ? '-1' : '0'}
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
aria-labelledby={`assignmentTypeBlockedIcon${assignmentType}`}
|
||||
>
|
||||
{footnoteMarker}
|
||||
</a>
|
||||
@@ -27,14 +42,17 @@ function AssignmentTypeCell({ assignmentType, footnoteMarker, footnoteId }) {
|
||||
}
|
||||
|
||||
AssignmentTypeCell.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
assignmentType: PropTypes.string.isRequired,
|
||||
footnoteId: PropTypes.string,
|
||||
footnoteMarker: PropTypes.number,
|
||||
locked: PropTypes.bool,
|
||||
};
|
||||
|
||||
AssignmentTypeCell.defaultProps = {
|
||||
footnoteId: '',
|
||||
footnoteMarker: null,
|
||||
locked: false,
|
||||
};
|
||||
|
||||
export default AssignmentTypeCell;
|
||||
export default injectIntl(AssignmentTypeCell);
|
||||
|
||||
@@ -5,11 +5,15 @@ import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
function DroppableAssignmentFootnote({ footnotes, intl }) {
|
||||
const {
|
||||
gradesFeatureIsLocked,
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
return (
|
||||
<>
|
||||
<span id="grade-summary-footnote-label" className="sr-only">{intl.formatMessage(messages.footnotesTitle)}</span>
|
||||
@@ -25,7 +29,7 @@ function DroppableAssignmentFootnote({ footnotes, intl }) {
|
||||
assignmentType: footnote.assignmentType,
|
||||
}}
|
||||
/>
|
||||
<a className="sr-only" href={`#${footnote.id}-ref`} tabIndex={gradesFeatureIsLocked ? '-1' : '0'}>
|
||||
<a className="sr-only" href={`#${footnote.id}-ref`} tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}>
|
||||
{intl.formatMessage(messages.backToContent)}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
@@ -16,14 +16,16 @@ function GradeSummary() {
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false);
|
||||
|
||||
if (assignmentPolicies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="text-dark-700 mb-4">
|
||||
<GradeSummaryHeader />
|
||||
<GradeSummaryTable />
|
||||
<GradeSummaryHeader allOfSomeAssignmentTypeIsLocked={allOfSomeAssignmentTypeIsLocked} />
|
||||
<GradeSummaryTable setAllOfSomeAssignmentTypeIsLocked={setAllOfSomeAssignmentTypeIsLocked} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon, IconButton, OverlayTrigger, Popover,
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline } from '@edx/paragon/icons';
|
||||
import { Blocked, InfoOutline } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
function GradeSummaryHeader({ intl }) {
|
||||
function GradeSummaryHeader({ intl, allOfSomeAssignmentTypeIsLocked }) {
|
||||
const {
|
||||
gradesFeatureIsLocked,
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="row w-100 m-0 align-items-center">
|
||||
<h3 className="h4 mb-3 mr-1">{intl.formatMessage(messages.gradeSummary)}</h3>
|
||||
@@ -37,15 +43,22 @@ function GradeSummaryHeader({ intl }) {
|
||||
iconAs={Icon}
|
||||
className="mb-3"
|
||||
size="sm"
|
||||
disabled={gradesFeatureIsLocked}
|
||||
disabled={gradesFeatureIsFullyLocked}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
{!gradesFeatureIsFullyLocked && allOfSomeAssignmentTypeIsLocked && (
|
||||
<div className="mb-3 small ml-0 d-inline">
|
||||
<Icon className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" />
|
||||
{intl.formatMessage(messages.gradeSummaryLimitedAccessExplanation)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GradeSummaryHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
allOfSomeAssignmentTypeIsLocked: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradeSummaryHeader);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -11,7 +12,7 @@ import GradeSummaryTableFooter from './GradeSummaryTableFooter';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function GradeSummaryTable({ intl }) {
|
||||
function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -20,6 +21,8 @@ function GradeSummaryTable({ intl }) {
|
||||
gradingPolicy: {
|
||||
assignmentPolicies,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const footnotes = [];
|
||||
@@ -29,6 +32,23 @@ function GradeSummaryTable({ intl }) {
|
||||
return footnoteId.replace(/[^A-Za-z0-9.-_]+/g, '-');
|
||||
};
|
||||
|
||||
const hasNoAccessToAssignmentsOfType = (assignmentType) => {
|
||||
const subsectionAssignmentsOfType = sectionScores.map((chapter) => chapter.subsections.filter((subsection) => (
|
||||
subsection.assignmentType === assignmentType && subsection.hasGradedAssignment
|
||||
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)
|
||||
))).flat();
|
||||
if (subsectionAssignmentsOfType.length) {
|
||||
const noAccessToAssignmentsOfType = !subsectionAssignmentsOfType.some((subsection) => (
|
||||
subsection.learnerHasAccess === true
|
||||
));
|
||||
if (noAccessToAssignmentsOfType) {
|
||||
setAllOfSomeAssignmentTypeIsLocked(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const gradeSummaryData = assignmentPolicies.map((assignment) => {
|
||||
let footnoteId = '';
|
||||
let footnoteMarker;
|
||||
@@ -44,11 +64,15 @@ function GradeSummaryTable({ intl }) {
|
||||
footnoteMarker = footnotes.length;
|
||||
}
|
||||
|
||||
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type) ? 'greyed-out' : '';
|
||||
|
||||
return {
|
||||
type: { footnoteId, footnoteMarker, type: assignment.type },
|
||||
weight: `${(assignment.weight * 100).toFixed(0)}%`,
|
||||
grade: `${(assignment.averageGrade * 100).toFixed(0)}%`,
|
||||
weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}%`,
|
||||
type: {
|
||||
footnoteId, footnoteMarker, type: assignment.type, locked,
|
||||
},
|
||||
weight: { weight: `${(assignment.weight * 100).toFixed(0)}%`, locked },
|
||||
grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}%`, locked },
|
||||
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}%`, locked },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,6 +91,7 @@ function GradeSummaryTable({ intl }) {
|
||||
assignmentType={value.type} // eslint-disable-line react/prop-types
|
||||
footnoteId={value.footnoteId} // eslint-disable-line react/prop-types
|
||||
footnoteMarker={value.footnoteMarker} // eslint-disable-line react/prop-types
|
||||
locked={value.locked} // eslint-disable-line react/prop-types
|
||||
/>
|
||||
),
|
||||
headerClassName: 'h5 mb-0',
|
||||
@@ -75,18 +100,30 @@ function GradeSummaryTable({ intl }) {
|
||||
Header: `${intl.formatMessage(messages.weight)}`,
|
||||
accessor: 'weight',
|
||||
headerClassName: 'justify-content-end h5 mb-0',
|
||||
// eslint-disable-next-line react/prop-types
|
||||
Cell: ({ value }) => (
|
||||
<span className={value.locked ? 'greyed-out' : ''}>{value.weight}</span> // eslint-disable-line react/prop-types
|
||||
),
|
||||
cellClassName: 'float-right small',
|
||||
},
|
||||
{
|
||||
Header: `${intl.formatMessage(messages.grade)}`,
|
||||
accessor: 'grade',
|
||||
headerClassName: 'justify-content-end h5 mb-0',
|
||||
// eslint-disable-next-line react/prop-types
|
||||
Cell: ({ value }) => (
|
||||
<span className={value.locked ? 'greyed-out' : ''}>{value.grade}</span> // eslint-disable-line react/prop-types
|
||||
),
|
||||
cellClassName: 'float-right small',
|
||||
},
|
||||
{
|
||||
Header: `${intl.formatMessage(messages.weightedGrade)}`,
|
||||
accessor: 'weightedGrade',
|
||||
headerClassName: 'justify-content-end h5 mb-0 text-right',
|
||||
// eslint-disable-next-line react/prop-types
|
||||
Cell: ({ value }) => (
|
||||
<span className={value.locked ? 'greyed-out' : ''}>{value.weightedGrade}</span> // eslint-disable-line react/prop-types
|
||||
),
|
||||
cellClassName: 'float-right font-weight-bold small',
|
||||
},
|
||||
]}
|
||||
@@ -104,6 +141,7 @@ function GradeSummaryTable({ intl }) {
|
||||
|
||||
GradeSummaryTable.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
setAllOfSomeAssignmentTypeIsLocked: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradeSummaryTable);
|
||||
|
||||
@@ -29,10 +29,14 @@ const messages = defineMessages({
|
||||
id: 'progress.courseGrade.footer.passing',
|
||||
defaultMessage: 'You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)',
|
||||
},
|
||||
courseGradePreviewHeader: {
|
||||
id: 'progress.courseGrade.preview.header',
|
||||
courseGradePreviewHeaderLocked: {
|
||||
id: 'progress.courseGrade.preview.headerLocked',
|
||||
defaultMessage: 'locked feature',
|
||||
},
|
||||
courseGradePreviewHeaderLimited: {
|
||||
id: 'progress.courseGrade.preview.headerLimited',
|
||||
defaultMessage: 'limited feature',
|
||||
},
|
||||
courseGradePreviewHeaderAriaHidden: {
|
||||
id: 'progress.courseGrade.preview.header.ariaHidden',
|
||||
defaultMessage: 'Preview of a ',
|
||||
@@ -41,6 +45,10 @@ const messages = defineMessages({
|
||||
id: 'progress.courseGrade.preview.body.unlockCertificate',
|
||||
defaultMessage: 'Unlock to view grades and work towards a certificate.',
|
||||
},
|
||||
courseGradePartialPreviewUnlockCertificateBody: {
|
||||
id: 'progress.courseGrade.partialpreview.body.unlockCertificate',
|
||||
defaultMessage: 'Unlock to work towards a certificate.',
|
||||
},
|
||||
courseGradePreviewUpgradeDeadlinePassedBody: {
|
||||
id: 'progress.courseGrade.preview.body.upgradeDeadlinePassed',
|
||||
defaultMessage: 'The deadline to upgrade in this course has passed.',
|
||||
@@ -89,6 +97,10 @@ const messages = defineMessages({
|
||||
id: 'progress.gradeSummary',
|
||||
defaultMessage: 'Grade summary',
|
||||
},
|
||||
gradeSummaryLimitedAccessExplanation: {
|
||||
id: 'progress.gradeSummary.limitedAccessExplanation',
|
||||
defaultMessage: 'You have limited access to graded assignments as part of the audit track in this course.',
|
||||
},
|
||||
gradeSummaryTooltipAlt: {
|
||||
id: 'progress.gradeSummary.tooltip.alt',
|
||||
defaultMessage: 'Grade summary tooltip',
|
||||
@@ -103,6 +115,14 @@ const messages = defineMessages({
|
||||
id: 'progress.courseGrade.label.passingGrade',
|
||||
defaultMessage: 'Passing grade',
|
||||
},
|
||||
problemScoreLabel: {
|
||||
id: 'progress.detailedGrades.problemScore.label',
|
||||
defaultMessage: 'Problem Scores:',
|
||||
},
|
||||
problemScoreToggleAltText: {
|
||||
id: 'progress.detailedGrades.problemScore.toggleButton',
|
||||
defaultMessage: 'Toggle individual problem scores for {subsectionTitle}',
|
||||
},
|
||||
score: {
|
||||
id: 'progress.score',
|
||||
defaultMessage: 'Score',
|
||||
@@ -119,6 +139,14 @@ const messages = defineMessages({
|
||||
id: 'progress.weightedGradeSummary',
|
||||
defaultMessage: 'Your current weighted grade summary',
|
||||
},
|
||||
noAcessToAssignmentType: {
|
||||
id: 'progress.noAcessToAssignmentType',
|
||||
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
|
||||
},
|
||||
noAcessToSubsection: {
|
||||
id: 'progress.noAcessToSubsection',
|
||||
defaultMessage: 'You do not have access to subsection {displayName}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -2,8 +2,6 @@ import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { getLocale } from '@edx/frontend-platform/i18n';
|
||||
import { Redirect } from 'react-router';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { defaultMemoize as memoize } from 'reselect';
|
||||
|
||||
@@ -19,15 +17,21 @@ import { TabPage } from '../tab-page';
|
||||
import Course from './course';
|
||||
import { handleNextSectionCelebration } from './course/celebration';
|
||||
|
||||
const checkUrlLength = memoize((shortLinkFeatureFlag, courseStatus, courseId, sequence, unitHashKey) => {
|
||||
if (shortLinkFeatureFlag && courseStatus === 'loaded' && sequence && unitHashKey) {
|
||||
history.replace(`/c/${courseId}/${sequence.hash_key}/${unitHashKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
||||
if (courseStatus === 'loaded' && !sequenceId) {
|
||||
// Note that getResumeBlock is just an API call, not a redux thunk.
|
||||
getResumeBlock(courseId).then((data) => {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
if (data.sectionId && data.unitId) {
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
history.replace(`/c/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
} else if (firstSequenceId) {
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
history.replace(`/c/${courseId}/${firstSequenceId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -35,7 +39,7 @@ const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSe
|
||||
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
||||
history.replace(`/course/${courseId}/${unitId}`);
|
||||
history.replace(`/c/${courseId}/${unitId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,10 +47,10 @@ const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequence
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
|
||||
// If the section is non-empty, redirect to its first sequence.
|
||||
if (section.sequenceIds && section.sequenceIds[0]) {
|
||||
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
|
||||
history.replace(`/c/${courseId}/${section.sequenceIds[0]}`);
|
||||
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
||||
} else {
|
||||
history.replace(`/course/${courseId}`);
|
||||
history.replace(`/c/${courseId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -55,7 +59,7 @@ const checkUnitToSequenceUnitRedirect = memoize((courseStatus, courseId, sequenc
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && unit) {
|
||||
// If the sequence failed to load as a sequence, but it *did* load as a unit, then
|
||||
// insert the unit's parent sequenceId into the URL.
|
||||
history.replace(`/course/${courseId}/${unit.sequenceId}/${unit.id}`);
|
||||
history.replace(`/c/${courseId}/${unit.sequenceId}/${unit.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,7 +78,7 @@ const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, s
|
||||
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
||||
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
history.replace(`/c/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -108,13 +112,13 @@ class CoursewareContainer extends Component {
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
sequenceId: routeSequenceHash,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
this.checkFetchSequence(routeSequenceId);
|
||||
this.checkFetchSequence(routeSequenceHash);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
@@ -129,18 +133,28 @@ class CoursewareContainer extends Component {
|
||||
firstSequenceId,
|
||||
unitViaSequenceId,
|
||||
sectionViaSequenceId,
|
||||
unitIdHashKeyMap,
|
||||
shortLinkFeatureFlag,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
sequenceId: routeSequenceHash,
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
this.checkFetchSequence(routeSequenceId);
|
||||
this.checkFetchSequence(routeSequenceHash);
|
||||
if (sequence && routeSequenceHash.includes('block') && unitIdHashKeyMap) {
|
||||
let unitHashKey;
|
||||
Object.values(unitIdHashKeyMap).forEach(id => {
|
||||
if (id === routeUnitId) {
|
||||
unitHashKey = Object.keys(unitIdHashKeyMap).find(key => unitIdHashKeyMap[key] === id);
|
||||
}
|
||||
});
|
||||
checkUrlLength(shortLinkFeatureFlag, courseStatus, courseId, sequence, unitHashKey);
|
||||
}
|
||||
|
||||
// All courseware URLs should normalize to the format /course/:courseId/:sequenceId/:unitId
|
||||
// via the series of redirection rules below.
|
||||
@@ -203,7 +217,7 @@ class CoursewareContainer extends Component {
|
||||
} = this.props;
|
||||
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
history.push(`/c/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}
|
||||
|
||||
handleNextSequenceClick = () => {
|
||||
@@ -213,16 +227,20 @@ class CoursewareContainer extends Component {
|
||||
nextSequence,
|
||||
sequence,
|
||||
sequenceId,
|
||||
shortLinkFeatureFlag,
|
||||
} = this.props;
|
||||
|
||||
if (nextSequence !== null) {
|
||||
let nextSequenceParam = nextSequence.id;
|
||||
if (shortLinkFeatureFlag) {
|
||||
nextSequenceParam = nextSequence.hash_key;
|
||||
}
|
||||
let nextUnitId = null;
|
||||
if (nextSequence.unitIds.length > 0) {
|
||||
[nextUnitId] = nextSequence.unitIds;
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
history.push(`/c/${courseId}/${nextSequenceParam}/${nextUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${nextSequence.id}`);
|
||||
history.push(`/c/${courseId}/${nextSequenceParam}`);
|
||||
}
|
||||
|
||||
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
|
||||
@@ -233,59 +251,34 @@ class CoursewareContainer extends Component {
|
||||
}
|
||||
|
||||
handlePreviousSequenceClick = () => {
|
||||
const { previousSequence, courseId } = this.props;
|
||||
const {
|
||||
previousSequence,
|
||||
courseId,
|
||||
shortLinkFeatureFlag,
|
||||
} = this.props;
|
||||
if (previousSequence !== null) {
|
||||
let previousSequenceParam = previousSequence.id;
|
||||
if (shortLinkFeatureFlag) {
|
||||
previousSequenceParam = previousSequence.hash_key;
|
||||
}
|
||||
if (previousSequence.unitIds.length > 0) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
history.push(`/c/${courseId}/${previousSequenceParam}/${previousUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${previousSequence.id}`);
|
||||
history.push(`/c/${courseId}/${previousSequenceParam}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderDenied() {
|
||||
const {
|
||||
course,
|
||||
courseId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
let url = `/redirect/course-home/${courseId}`;
|
||||
switch (course.canLoadCourseware.errorCode) {
|
||||
case 'audit_expired':
|
||||
url = `/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`;
|
||||
break;
|
||||
case 'course_not_started':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
|
||||
url = `/redirect/dashboard?notlive=${startDate}`;
|
||||
break;
|
||||
case 'survey_required': // TODO: Redirect to the course survey
|
||||
case 'unfulfilled_milestones':
|
||||
url = '/redirect/dashboard';
|
||||
break;
|
||||
case 'microfrontend_disabled':
|
||||
url = `/redirect/courseware/${courseId}/unit/${routeUnitId}`;
|
||||
break;
|
||||
case 'authentication_required':
|
||||
case 'enrollment_required':
|
||||
default:
|
||||
}
|
||||
return (
|
||||
<Redirect to={url} />
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
courseStatus,
|
||||
courseId,
|
||||
sequenceId,
|
||||
sequence,
|
||||
shortLinkFeatureFlag,
|
||||
unitIdHashKeyMap,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
@@ -293,22 +286,30 @@ class CoursewareContainer extends Component {
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (courseStatus === 'denied') {
|
||||
return this.renderDenied();
|
||||
// This helps process old URLS that still use a blocks usage key in the URL.
|
||||
let updatedSequenceId;
|
||||
let updatedUnitId;
|
||||
if (shortLinkFeatureFlag && sequence) {
|
||||
if (!sequenceId.includes('block')) {
|
||||
updatedSequenceId = sequence.id;
|
||||
}
|
||||
if (routeUnitId && !routeUnitId.includes('block')) {
|
||||
updatedUnitId = unitIdHashKeyMap[routeUnitId];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPage
|
||||
activeTabSlug="courseware"
|
||||
courseId={courseId}
|
||||
unitId={routeUnitId}
|
||||
unitId={updatedUnitId || routeUnitId}
|
||||
courseStatus={courseStatus}
|
||||
metadataModel="coursewareMeta"
|
||||
>
|
||||
<Course
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={routeUnitId}
|
||||
sequenceId={updatedSequenceId || sequenceId}
|
||||
unitId={updatedUnitId || routeUnitId}
|
||||
nextSequenceHandler={this.handleNextSequenceClick}
|
||||
previousSequenceHandler={this.handlePreviousSequenceClick}
|
||||
unitNavigationHandler={this.handleUnitNavigationClick}
|
||||
@@ -327,6 +328,7 @@ const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
sectionId: PropTypes.string.isRequired,
|
||||
hash_key: PropTypes.string.isRequired,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
isProctored: PropTypes.bool,
|
||||
legacyWebUrl: PropTypes.string,
|
||||
@@ -338,10 +340,9 @@ const sectionShape = PropTypes.shape({
|
||||
});
|
||||
|
||||
const courseShape = PropTypes.shape({
|
||||
canLoadCourseware: PropTypes.shape({
|
||||
errorCode: PropTypes.string,
|
||||
additionalContextUserMessage: PropTypes.string,
|
||||
}).isRequired,
|
||||
celebrations: PropTypes.shape({
|
||||
firstSection: PropTypes.bool,
|
||||
}),
|
||||
});
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
@@ -361,6 +362,7 @@ CoursewareContainer.propTypes = {
|
||||
previousSequence: sequenceShape,
|
||||
unitViaSequenceId: unitShape,
|
||||
sectionViaSequenceId: sectionShape,
|
||||
unitIdHashKeyMap: unitShape,
|
||||
course: courseShape,
|
||||
sequence: sequenceShape,
|
||||
saveSequencePosition: PropTypes.func.isRequired,
|
||||
@@ -369,6 +371,7 @@ CoursewareContainer.propTypes = {
|
||||
fetchSequence: PropTypes.func.isRequired,
|
||||
specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
|
||||
proctoredExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
|
||||
shortLinkFeatureFlag: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
CoursewareContainer.defaultProps = {
|
||||
@@ -381,6 +384,7 @@ CoursewareContainer.defaultProps = {
|
||||
sectionViaSequenceId: null,
|
||||
course: null,
|
||||
sequence: null,
|
||||
unitIdHashKeyMap: null,
|
||||
};
|
||||
|
||||
const currentCourseSelector = createSelector(
|
||||
@@ -392,7 +396,16 @@ const currentCourseSelector = createSelector(
|
||||
const currentSequenceSelector = createSelector(
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequencesById, sequenceId) => (sequencesById[sequenceId] ? sequencesById[sequenceId] : null),
|
||||
(state) => state.models.sequenceIdToHashKeyMap,
|
||||
(sequencesById, sequenceId, sequenceMap) => {
|
||||
if (!sequencesById[sequenceId] && Object.keys(sequencesById).length > 0 && sequenceMap) {
|
||||
if (sequenceId in sequenceMap) {
|
||||
const updatedSequenceId = sequenceMap[sequenceId];
|
||||
return sequencesById[updatedSequenceId];
|
||||
}
|
||||
}
|
||||
return sequencesById[sequenceId] ? sequencesById[sequenceId] : null;
|
||||
},
|
||||
);
|
||||
|
||||
const sequenceIdsSelector = createSelector(
|
||||
@@ -412,11 +425,18 @@ const previousSequenceSelector = createSelector(
|
||||
sequenceIdsSelector,
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequenceIds, sequencesById, sequenceId) => {
|
||||
(state) => state.models.sequenceIdToHashKeyMap,
|
||||
(sequenceIds, sequencesById, sequenceId, sequenceMap) => {
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
let sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
if (!sequencesById[sequenceId] && Object.keys(sequencesById).length > 0 && sequenceMap) {
|
||||
if (sequenceId in sequenceMap) {
|
||||
const updatedSequenceId = sequenceMap[sequenceId];
|
||||
sequenceIndex = sequenceIds.indexOf(updatedSequenceId);
|
||||
}
|
||||
}
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
|
||||
},
|
||||
@@ -426,11 +446,18 @@ const nextSequenceSelector = createSelector(
|
||||
sequenceIdsSelector,
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequenceIds, sequencesById, sequenceId) => {
|
||||
(state) => state.models.sequenceIdToHashKeyMap,
|
||||
(sequenceIds, sequencesById, sequenceId, sequenceMap) => {
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
let sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
if (!sequencesById[sequenceId] && Object.keys(sequencesById).length > 0 && sequenceMap) {
|
||||
if (sequenceId in sequenceMap) {
|
||||
const updatedSequenceId = sequenceMap[sequenceId];
|
||||
sequenceIndex = sequenceIds.indexOf(updatedSequenceId);
|
||||
}
|
||||
}
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
return nextSequenceId !== null ? sequencesById[nextSequenceId] : null;
|
||||
},
|
||||
@@ -463,7 +490,21 @@ const sectionViaSequenceIdSelector = createSelector(
|
||||
const unitViaSequenceIdSelector = createSelector(
|
||||
(state) => state.models.units || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(unitsById, sequenceId) => (unitsById[sequenceId] ? unitsById[sequenceId] : null),
|
||||
(state) => state.models.unitIdHashKeyMap,
|
||||
(unitsById, sequenceId, unitMap) => {
|
||||
if (!unitsById[sequenceId] && Object.keys(unitsById).length > 0 && unitMap) {
|
||||
if (sequenceId in unitMap) {
|
||||
const updatedSequenceId = unitMap[sequenceId];
|
||||
return unitsById[updatedSequenceId];
|
||||
}
|
||||
}
|
||||
return unitsById[sequenceId] ? unitsById[sequenceId] : null;
|
||||
},
|
||||
);
|
||||
|
||||
const unitIdHashKeyMapSelector = createSelector(
|
||||
(state) => state.models.unitIdToHashKeyMap,
|
||||
(unitIdToHashKeyMap) => (unitIdToHashKeyMap),
|
||||
);
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
@@ -474,6 +515,7 @@ const mapStateToProps = (state) => {
|
||||
sequenceStatus,
|
||||
specialExamsEnabledWaffleFlag,
|
||||
proctoredExamsEnabledWaffleFlag,
|
||||
shortLinkFeatureFlag,
|
||||
} = state.courseware;
|
||||
|
||||
return {
|
||||
@@ -483,6 +525,7 @@ const mapStateToProps = (state) => {
|
||||
sequenceStatus,
|
||||
specialExamsEnabledWaffleFlag,
|
||||
proctoredExamsEnabledWaffleFlag,
|
||||
shortLinkFeatureFlag,
|
||||
course: currentCourseSelector(state),
|
||||
sequence: currentSequenceSelector(state),
|
||||
previousSequence: previousSequenceSelector(state),
|
||||
@@ -490,6 +533,7 @@ const mapStateToProps = (state) => {
|
||||
firstSequenceId: firstSequenceIdSelector(state),
|
||||
sectionViaSequenceId: sectionViaSequenceIdSelector(state),
|
||||
unitViaSequenceId: unitViaSequenceIdSelector(state),
|
||||
unitIdHashKeyMap: unitIdHashKeyMapSelector(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ describe('CoursewareContainer', () => {
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
'/c/:courseId/:sequenceId/:unitId',
|
||||
'/c/:courseId/:sequenceId',
|
||||
'/c/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
@@ -128,8 +128,10 @@ describe('CoursewareContainer', () => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
|
||||
sequenceMetadatas.forEach(sequenceMetadata => {
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.hash_key}`;
|
||||
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
|
||||
const sequenceMetadataUrlFull = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
|
||||
axiosMock.onGet(sequenceMetadataUrlFull).reply(200, sequenceMetadata);
|
||||
const proctoredExamApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${courseId}/content_id/${sequenceMetadata.item_id}?is_learning_mfe=true`;
|
||||
axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} });
|
||||
});
|
||||
@@ -144,7 +146,7 @@ describe('CoursewareContainer', () => {
|
||||
}
|
||||
|
||||
it('should initialize to show a spinner', () => {
|
||||
history.push('/course/abc123');
|
||||
history.push('/c/abc123');
|
||||
render(component);
|
||||
|
||||
const spinner = screen.getByRole('status');
|
||||
@@ -190,11 +192,11 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
|
||||
sectionId: sequenceBlock.id,
|
||||
unitId: unitBlocks[1].id,
|
||||
sectionId: sequenceBlock.hash_key,
|
||||
unitId: unitBlocks[1].hash_key,
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
history.push(`/c/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -202,7 +204,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].hash_key);
|
||||
});
|
||||
|
||||
it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
|
||||
@@ -217,7 +219,7 @@ describe('CoursewareContainer', () => {
|
||||
// Note how there is no sectionId/unitId returned in this mock response!
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
history.push(`/c/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -237,11 +239,11 @@ describe('CoursewareContainer', () => {
|
||||
);
|
||||
|
||||
function setUrl(urlSequenceId, urlUnitId = null) {
|
||||
history.push(`/course/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
|
||||
history.push(`/c/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
|
||||
}
|
||||
|
||||
function assertLocation(container, sequenceId, unitId) {
|
||||
const expectedUrl = `http://localhost/course/${courseId}/${sequenceId}/${unitId}`;
|
||||
const expectedUrl = `http://localhost/c/${courseId}/${sequenceId}/${unitId}`;
|
||||
expect(global.location.href).toEqual(expectedUrl);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId);
|
||||
}
|
||||
@@ -257,7 +259,7 @@ describe('CoursewareContainer', () => {
|
||||
const container = await loadContainer();
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
|
||||
assertLocation(container, sequenceTree[1][1].hash_key, urlUnit.hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,7 +269,7 @@ describe('CoursewareContainer', () => {
|
||||
const container = await loadContainer();
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
||||
assertLocation(container, sequenceTree[1][0].hash_key, unitTree[1][0][0].hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,14 +295,14 @@ describe('CoursewareContainer', () => {
|
||||
it('should ignore the section ID and instead redirect to the course root', async () => {
|
||||
setUrl(sectionTree[1].id);
|
||||
await loadContainer();
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${courseId}`);
|
||||
});
|
||||
|
||||
it('should ignore the section and unit IDs and instead to the course root', async () => {
|
||||
// Specific unit ID used here shouldn't matter; is ignored due to empty section.
|
||||
setUrl(sectionTree[1].id, unitTree[0][0][0]);
|
||||
await loadContainer();
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${courseId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -314,15 +316,15 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should insert the sequence ID into the URL', async () => {
|
||||
const unit = unitTree[1][0][1];
|
||||
history.push(`/course/${courseId}/${unit.id}`);
|
||||
history.push(`/c/${courseId}/${unit.id}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
const expectedSequenceId = sequenceTree[1][0].id;
|
||||
const expectedUrl = `http://localhost/course/${courseId}/${expectedSequenceId}/${unit.id}`;
|
||||
const expectedSequenceId = sequenceTree[1][0].hash_key;
|
||||
const expectedUrl = `http://localhost/c/${courseId}/${expectedSequenceId}/${unit.hash_key}`;
|
||||
expect(global.location.href).toEqual(expectedUrl);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +333,7 @@ describe('CoursewareContainer', () => {
|
||||
const unitBlocks = defaultUnitBlocks;
|
||||
|
||||
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -339,7 +341,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].hash_key);
|
||||
});
|
||||
|
||||
it('should use activeUnitIndex to pick a unit from the sequence', async () => {
|
||||
@@ -350,7 +352,7 @@ describe('CoursewareContainer', () => {
|
||||
);
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -358,7 +360,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,7 +369,7 @@ describe('CoursewareContainer', () => {
|
||||
const unitBlocks = defaultUnitBlocks;
|
||||
|
||||
it('should load the specified unit', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[2].hash_key}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -375,7 +377,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].hash_key);
|
||||
});
|
||||
|
||||
it('should navigate between units and check block completion', async () => {
|
||||
@@ -383,7 +385,7 @@ describe('CoursewareContainer', () => {
|
||||
complete: true,
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[0].id}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
@@ -391,7 +393,7 @@ describe('CoursewareContainer', () => {
|
||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||
fireEvent.click(sequenceNavButtons[4]);
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[1].id}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +421,7 @@ describe('CoursewareContainer', () => {
|
||||
);
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[2].hash_key}`);
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.legacy_web_url);
|
||||
@@ -427,31 +429,46 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when receiving a can_load_courseware error_code', () => {
|
||||
describe('when receiving a course_access error_code', () => {
|
||||
function setUpWithDeniedStatus(errorCode) {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
can_load_courseware: {
|
||||
course_access: {
|
||||
has_access: false,
|
||||
error_code: errorCode,
|
||||
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
|
||||
},
|
||||
});
|
||||
const courseId = courseMetadata.id;
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
|
||||
|
||||
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
|
||||
setUpMockRequests({ courseBlocks, courseMetadata });
|
||||
history.push(`/course/${courseId}`);
|
||||
return courseMetadata;
|
||||
history.push(`/c/${courseId}/${sequenceBlocks[0].hash_key}/${unitBlocks[0].hash_key}`);
|
||||
return { courseMetadata, unitBlocks };
|
||||
}
|
||||
|
||||
it('should go to course home for an enrollment_required error code', async () => {
|
||||
const courseMetadata = setUpWithDeniedStatus('enrollment_required');
|
||||
const { courseMetadata } = setUpWithDeniedStatus('enrollment_required');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('should go to course survey for a survey_required error code', async () => {
|
||||
const { courseMetadata } = setUpWithDeniedStatus('survey_required');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('should go to legacy courseware for a microfrontend_disabled error code', async () => {
|
||||
const { courseMetadata, unitBlocks } = setUpWithDeniedStatus('microfrontend_disabled');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/courseware/${courseMetadata.id}/unit/${unitBlocks[0].id}`);
|
||||
});
|
||||
|
||||
it('should go to course home for an authentication_required error code', async () => {
|
||||
const courseMetadata = setUpWithDeniedStatus('authentication_required');
|
||||
const { courseMetadata } = setUpWithDeniedStatus('authentication_required');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
|
||||
@@ -25,12 +25,24 @@ export default () => {
|
||||
path={`${path}/courseware/:courseId/unit/:unitId`}
|
||||
component={CoursewareRedirect}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/:courseId/:sequenceId/:unitId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`/c/${match.params.courseId}/${match.params.sequenceId}/${match.params.unitId}`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/course-home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/survey/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/dashboard`}
|
||||
render={({ location }) => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
|
||||
|
||||
@@ -39,10 +38,7 @@ function Course({
|
||||
].filter(element => element != null).map(element => element.title);
|
||||
|
||||
const {
|
||||
canShowUpgradeSock,
|
||||
celebrations,
|
||||
offer,
|
||||
org,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
@@ -109,15 +105,6 @@ function Course({
|
||||
open
|
||||
/>
|
||||
)}
|
||||
{canShowUpgradeSock && (
|
||||
<CourseSock
|
||||
courseId={courseId}
|
||||
offer={offer}
|
||||
orgKey={org}
|
||||
pageLocation="Course Content Page"
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
)}
|
||||
<ContentTools course={course} />
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{ MMP2P.meta.modalLock && <MMP2PBlockModal options={MMP2P} /> }
|
||||
|
||||
@@ -79,14 +79,6 @@ describe('Course', () => {
|
||||
expect(getByRole(celebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays upgrade sock', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { can_show_upgrade_sock: true });
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
render(<Course {...mockData} />);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { WatchOutline } from '@edx/paragon/icons';
|
||||
|
||||
import './NotificationIcon.scss';
|
||||
import messages from './messages';
|
||||
|
||||
function NotificationIcon({ intl, status, notificationColor }) {
|
||||
|
||||
@@ -58,11 +58,12 @@ function NotificationTray({
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
shouldDisplayBorder={false}
|
||||
/>
|
||||
) : <p className="notification-tray-content">{intl.formatMessage(messages.noNotificationsMessage)}</p>}
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ class Calculator extends Component {
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Body className="calculator-content pt-4">
|
||||
<form onSubmit={this.handleSubmit} className="container-fluid form-inline flex-nowrap">
|
||||
<form onSubmit={this.handleSubmit} className="container-xl form-inline flex-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
|
||||
@@ -80,7 +80,7 @@ class Calculator extends Component {
|
||||
</form>
|
||||
|
||||
<Collapsible.Advanced>
|
||||
<div className="container-fluid">
|
||||
<div className="container-xl">
|
||||
<Collapsible.Trigger className="btn btn-link btn-sm px-0 d-inline-flex align-items-center">
|
||||
<Collapsible.Visible whenOpen>
|
||||
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
|
||||
@@ -94,7 +94,7 @@ class Calculator extends Component {
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Body className="container-fluid pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
|
||||
<Collapsible.Body className="container-xl pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
|
||||
<FormattedMessage
|
||||
tagName="h6"
|
||||
id="calculator.instructions"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { layoutGenerator } from 'react-break';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||
import { CheckCircle } from '@edx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
@@ -285,34 +286,37 @@ function CourseCelebration({ intl }) {
|
||||
</div>
|
||||
<div className="col-12 px-0 px-md-5">
|
||||
{certHeader && (
|
||||
<Alert variant="success" className="row w-100 m-0">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{certHeader}</div>
|
||||
{message}
|
||||
<div className="mt-2">
|
||||
{buttonPrefix}
|
||||
{buttonLocation && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
href={buttonLocation}
|
||||
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{buttonSuffix}
|
||||
<Alert variant="success" icon={CheckCircle}>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{certHeader}</div>
|
||||
{message}
|
||||
<div className="mt-2">
|
||||
{buttonPrefix}
|
||||
{buttonLocation && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
href={buttonLocation}
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{buttonSuffix}
|
||||
</div>
|
||||
</div>
|
||||
{certStatus !== 'unverified' && (
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{certStatus !== 'unverified' && (
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{relatedPrograms && relatedPrograms.map(program => (
|
||||
|
||||
@@ -40,7 +40,7 @@ function CourseExit({ intl }) {
|
||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||
body = (<CourseCelebration />);
|
||||
} else {
|
||||
return (<Redirect to={`/course/${courseId}`} />);
|
||||
return (<Redirect to={`/c/${courseId}`} />);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('Course Exit Pages', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${defaultMetadata.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,13 +34,13 @@ function CourseInProgress({ intl }) {
|
||||
<div className="col-12 p-0 h2 text-center">
|
||||
{ intl.formatMessage(messages.courseInProgressHeader) }
|
||||
</div>
|
||||
<Alert variant="primary" className="col col-lg-10 mt-4 d-flex">
|
||||
<Alert variant="primary" className="mt-4">
|
||||
<div className="row w-100 m-0 align-items-start">
|
||||
<div className="flex-grow-1 col-md p-0">{ intl.formatMessage(messages.courseInProgressDescription) }</div>
|
||||
<div className="col-md p-0">{ intl.formatMessage(messages.courseInProgressDescription) }</div>
|
||||
{datesTabLink && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-shrink-0 mt-3 mt-md-0 mb-1 mb-md-0 ml-md-5"
|
||||
className="mt-3 my-md-0 mb-1 ml-md-5 w-xs-100 w-md-auto"
|
||||
href={datesTabLink}
|
||||
onClick={() => logClick(org, courseId, administrator, 'view_dates_tab')}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ function CourseNonPassing({ intl }) {
|
||||
<div className="col-12 p-0 h2 text-center">
|
||||
{ intl.formatMessage(messages.endOfCourseHeader) }
|
||||
</div>
|
||||
<Alert variant="primary" className="col col-lg-10 mt-4 d-flex">
|
||||
<Alert variant="primary" className="col col-lg-10 mt-4">
|
||||
<div className="row w-100 m-0 align-items-start">
|
||||
<div className="flex-grow-1 col-sm p-0">{ intl.formatMessage(messages.endOfCourseDescription) }</div>
|
||||
{progressLink && (
|
||||
|
||||
@@ -41,54 +41,56 @@ function ProgramCompletion({
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="primary" className="row w-100 mx-0 my-3" data-testid="program-completion">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{intl.formatMessage(messages.programsLastCourseHeader, { title })}</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseExit.programCompletion.dashboardMessage"
|
||||
defaultMessage="To view your certificate status, check the Programs section of your {programLink}."
|
||||
values={{ programLink }}
|
||||
/>
|
||||
</p>
|
||||
{type === 'microbachelors' && (
|
||||
<>
|
||||
<Alert variant="primary" className="my-3" data-testid="program-completion">
|
||||
<div className="d-flex">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{intl.formatMessage(messages.programsLastCourseHeader, { title })}</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseExit.programCompletion.dashboardMessage"
|
||||
defaultMessage="To view your certificate status, check the Programs section of your {programLink}."
|
||||
values={{ programLink }}
|
||||
/>
|
||||
</p>
|
||||
{type === 'microbachelors' && (
|
||||
<>
|
||||
<p>
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/360004623154`}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.microBachelorsLearnMore)}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<Button variant="primary" className="mb-2 mb-sm-0" href={`${getConfig().CREDENTIALS_BASE_URL}/records`}>
|
||||
{intl.formatMessage(messages.applyForCredit)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{type === 'micromasters' && (
|
||||
<p>
|
||||
{intl.formatMessage(messages.microMastersMessage)}
|
||||
{' '}
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/360004623154`}
|
||||
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/360010346853-Does-a-Micromasters-certificate-count-towards-the-online-Master-s-degree-`}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.microBachelorsLearnMore)}
|
||||
{intl.formatMessage(messages.microMastersLearnMore)}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<Button variant="primary" className="mb-2 mb-sm-0" href={`${getConfig().CREDENTIALS_BASE_URL}/records`}>
|
||||
{intl.formatMessage(messages.applyForCredit)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{type === 'micromasters' && (
|
||||
<p>
|
||||
{intl.formatMessage(messages.microMastersMessage)}
|
||||
{' '}
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/360010346853-Does-a-Micromasters-certificate-count-towards-the-online-Master-s-degree-`}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.microMastersLearnMore)}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certImage}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
data-testid={type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certImage}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
data-testid={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -161,7 +161,7 @@ function Sequence({
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
const goToCourseExitPage = () => {
|
||||
history.push(`/course/${courseId}/course-end`);
|
||||
history.push(`/c/${courseId}/course-end`);
|
||||
};
|
||||
|
||||
const defaultContent = (
|
||||
@@ -204,6 +204,7 @@ function Sequence({
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
@@ -243,7 +244,7 @@ function Sequence({
|
||||
if (sequenceStatus === 'loaded') {
|
||||
return (
|
||||
<div>
|
||||
<SequenceExamWrapper sequence={sequence} courseId={courseId}>
|
||||
<SequenceExamWrapper sequence={sequence} courseId={courseId} isStaff={course.isStaff}>
|
||||
{defaultContent}
|
||||
</SequenceExamWrapper>
|
||||
<CourseLicense license={course.license || undefined} />
|
||||
|
||||
@@ -16,6 +16,7 @@ function SequenceContent({
|
||||
sequenceId,
|
||||
unitId,
|
||||
unitLoadedHandler,
|
||||
notificationTrayVisible,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
@@ -61,6 +62,7 @@ function SequenceContent({
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={unitLoadedHandler}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
@@ -73,6 +75,7 @@ SequenceContent.propTypes = {
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
unitLoadedHandler: PropTypes.func.isRequired,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
|
||||
@@ -85,12 +85,14 @@ function Unit({
|
||||
onLoaded,
|
||||
id,
|
||||
intl,
|
||||
notificationTrayVisible,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
const unit = useModel('units', id);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${(unit.decoded_id || id)}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
if (format) {
|
||||
iframeUrl += `&format=${format}`;
|
||||
}
|
||||
@@ -100,7 +102,6 @@ function Unit({
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
@@ -171,7 +172,7 @@ function Unit({
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
<LockPaywall courseId={courseId} notificationTrayVisible={notificationTrayVisible} />
|
||||
</Suspense>
|
||||
)}
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
@@ -252,6 +253,7 @@ Unit.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('Unit', () => {
|
||||
id: unit.id,
|
||||
courseId: courseMetadata.id,
|
||||
format: 'Homework',
|
||||
decoded_id: unit.decoded_id,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,7 +54,7 @@ describe('Unit', () => {
|
||||
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}`,
|
||||
'src', `http://localhost:18000/xblock/${mockData.decoded_id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function ContentLock({
|
||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
history.push(`/course/${courseId}/${prereqId}`);
|
||||
history.push(`/c/${courseId}/${prereqId}`);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,6 +38,6 @@ describe('Content Lock', () => {
|
||||
render(<ContentLock {...mockData} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
expect(history.push).toHaveBeenCalledWith(`/c/${mockData.courseId}/${mockData.prereqId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ function HonorCode({ intl, courseId }) {
|
||||
const siteName = getConfig().SITE_NAME;
|
||||
const honorCodeUrl = `${process.env.TERMS_OF_SERVICE_URL}#honor-code`;
|
||||
|
||||
const handleCancel = () => history.push(`/course/${courseId}/home`);
|
||||
const handleCancel = () => history.push(`/c/${courseId}/home`);
|
||||
|
||||
const handleAgree = () => {
|
||||
dispatch(saveIntegritySignature(courseId));
|
||||
|
||||
@@ -28,6 +28,6 @@ describe('Honor Code', () => {
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
expect(history.push).toHaveBeenCalledWith(`/c/${mockData.courseId}/home`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
import certificateLocked from '../../../../generic/assets/edX_locked_certificate.png';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../../../generic/tabs/useWindowSize';
|
||||
import { UpgradeButton } from '../../../../generic/upgrade-button';
|
||||
import './LockPaywall.scss';
|
||||
|
||||
function LockPaywall({
|
||||
intl,
|
||||
courseId,
|
||||
notificationTrayVisible,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
@@ -25,6 +25,18 @@ function LockPaywall({
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
// the following variables are set and used for resposive layout to work with
|
||||
// whether the NotificationTray is open or not and if there's an offer with longer text
|
||||
const shouldDisplayBulletPointsBelowCertificate = useWindowSize().width
|
||||
<= responsiveBreakpoints.large.minWidth;
|
||||
const shouldDisplayGatedContentOneColumn = useWindowSize().width <= responsiveBreakpoints.extraLarge.minWidth
|
||||
&& notificationTrayVisible;
|
||||
const shouldDisplayGatedContentTwoColumns = useWindowSize().width < responsiveBreakpoints.large.minWidth
|
||||
&& notificationTrayVisible;
|
||||
const shouldDisplayGatedContentTwoColumnsHalf = useWindowSize().width <= responsiveBreakpoints.large.minWidth
|
||||
&& !notificationTrayVisible;
|
||||
const shouldWrapTextOnButton = useWindowSize().width > responsiveBreakpoints.extraSmall.minWidth;
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
@@ -44,14 +56,6 @@ function LockPaywall({
|
||||
});
|
||||
};
|
||||
|
||||
const lockIcon = (
|
||||
<Icon
|
||||
className="float-left"
|
||||
src={Locked}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
|
||||
const verifiedCertLink = (
|
||||
<Alert.Link
|
||||
href="https://www.edx.org/verified-certificate"
|
||||
@@ -79,12 +83,8 @@ function LockPaywall({
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="light" aria-live="off">
|
||||
<Alert variant="light" aria-live="off" icon={Locked} className="lock-paywall-container">
|
||||
<div className="row">
|
||||
<div className="col-auto px-0">
|
||||
{lockIcon}
|
||||
</div>
|
||||
|
||||
<div className="col">
|
||||
<h4 aria-level="3">
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
|
||||
@@ -94,7 +94,7 @@ function LockPaywall({
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content'])}
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-row flex-wrap">
|
||||
<div className={classNames('d-flex flex-row', { 'flex-wrap': notificationTrayVisible || shouldDisplayBulletPointsBelowCertificate })}>
|
||||
<div style={{ float: 'left' }} className="mr-3 mb-2">
|
||||
<img
|
||||
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
|
||||
@@ -148,13 +148,17 @@ function LockPaywall({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="col-md-auto p-md-0 d-md-flex align-items-md-center mr-md-3"
|
||||
style={{ textAlign: 'right' }}
|
||||
className={
|
||||
classNames('p-md-0 d-md-flex align-items-md-center text-right', {
|
||||
'col-md-5 mx-md-0': notificationTrayVisible, 'col-md-4 mx-md-3 justify-content-center': !notificationTrayVisible && !shouldDisplayGatedContentTwoColumnsHalf, 'col-md-11 justify-content-end': shouldDisplayGatedContentOneColumn && !shouldDisplayGatedContentTwoColumns, 'col-md-6 justify-content-center': shouldDisplayGatedContentTwoColumnsHalf,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
style={{ whiteSpace: shouldWrapTextOnButton ? 'nowrap' : null }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,5 +168,6 @@ function LockPaywall({
|
||||
LockPaywall.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
};
|
||||
export default injectIntl(LockPaywall);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.lock-paywall-container svg {
|
||||
color: $primary-700;
|
||||
}
|
||||
|
||||
// Temporary CSS intervention until paragon list items will support icons (PAR-429)
|
||||
.fa-li {
|
||||
left: -31px !important;
|
||||
padding-right: 22px;
|
||||
|
||||
@@ -3,7 +3,12 @@ import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
injectIntl,
|
||||
intlShape,
|
||||
isRtl,
|
||||
getLocale,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getCourseExitNavigation } from '../../course-exit';
|
||||
@@ -67,16 +72,20 @@ function SequenceNavigation({
|
||||
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={ChevronRight}>
|
||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
|
||||
{shouldDisplayNotificationTrigger ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
<nav className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTrigger ? '90%' : null }}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={ChevronLeft}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
||||
{shouldDisplayNotificationTrigger ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderUnitButtons()}
|
||||
|
||||
@@ -3,7 +3,9 @@ import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
injectIntl, intlShape, isRtl, getLocale,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getCourseExitNavigation } from '../../course-exit';
|
||||
@@ -28,6 +30,7 @@ function UnitNavigation({
|
||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
|
||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||
const disabled = isLastUnit && !exitActive;
|
||||
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
|
||||
return (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
@@ -38,11 +41,12 @@ function UnitNavigation({
|
||||
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
|
||||
{buttonText}
|
||||
</UnitNavigationEffortEstimate>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
|
||||
<FontAwesomeIcon icon={nextArrow} className="ml-2" size="sm" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
|
||||
return (
|
||||
<div className="unit-navigation d-flex">
|
||||
<Button
|
||||
@@ -51,7 +55,7 @@ function UnitNavigation({
|
||||
disabled={isFirstUnit}
|
||||
onClick={onClickPrevious}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
|
||||
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
|
||||
{intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderNextButton()}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import EffortEstimate from '../../../../shared/effort-estimate';
|
||||
import { sequenceIdsSelector } from '../../../data';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
// This component exists to peek ahead at the next subsection or section and grab its estimated effort.
|
||||
// If we should be showing the next block's effort, we display the title and effort instead of "Next".
|
||||
// This code currently tries to handle both section and subsection estimates. But once AA-659 happens, it can be
|
||||
// simplified to one or the other code path.
|
||||
import messages from './messages';
|
||||
|
||||
function UnitNavigationEffortEstimate({ children, sequenceId, unitId }) {
|
||||
// This component exists to peek ahead at the next sequence and grab its estimated effort.
|
||||
// If we should be showing the next sequence's effort, we display the title and effort instead of "Next".
|
||||
|
||||
function UnitNavigationEffortEstimate({
|
||||
children,
|
||||
intl,
|
||||
sequenceId,
|
||||
unitId,
|
||||
}) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const nextSequence = useModel('sequences', nextSequenceId);
|
||||
const nextSection = useModel('sections', nextSequence ? nextSequence.sectionId : null);
|
||||
|
||||
if (!sequence || !nextSequence) {
|
||||
return children;
|
||||
@@ -28,32 +33,23 @@ function UnitNavigationEffortEstimate({ children, sequenceId, unitId }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
let blockToShow = nextSequence;
|
||||
// The experimentation code currently only sets effort on either sequences, sections, or nothing. If we don't have
|
||||
// sequence info, we are either doing sections or nothing. Let's look into it.
|
||||
// If we don't have info to show for the next sequence, just bail
|
||||
if (!nextSequence.effortActivities && !nextSequence.effortTime) {
|
||||
if (!nextSection.effortActivities && !nextSection.effortTime) {
|
||||
return children; // control group - no effort estimates at all
|
||||
}
|
||||
|
||||
// Are we at a section border? If so, let's show the next section's effort estimates
|
||||
if (sequence.sectionId !== nextSequence.sectionId) {
|
||||
blockToShow = nextSection;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
// Note: we don't use `children` here - we replace it with the next section name.
|
||||
// AA-659: remember to add a translation for Next Up
|
||||
// Note: we don't use `children` here - we replace it with the next sequence's title.
|
||||
return (
|
||||
<div className="d-inline-block text-wrap">
|
||||
Next Up: {blockToShow.title}
|
||||
<EffortEstimate className="d-block mt-1" block={blockToShow} />
|
||||
{intl.formatMessage(messages.nextUpButton, { title: nextSequence.title })}
|
||||
<EffortEstimate className="d-block mt-1" block={nextSequence} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UnitNavigationEffortEstimate.propTypes = {
|
||||
children: PropTypes.node,
|
||||
intl: intlShape.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
};
|
||||
@@ -63,4 +59,4 @@ UnitNavigationEffortEstimate.defaultProps = {
|
||||
unitId: null,
|
||||
};
|
||||
|
||||
export default UnitNavigationEffortEstimate;
|
||||
export default injectIntl(UnitNavigationEffortEstimate);
|
||||
|
||||
@@ -6,6 +6,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Next',
|
||||
description: 'Button to advance to the next section',
|
||||
},
|
||||
nextUpButton: {
|
||||
id: 'learn.sequence.navigation.next.up.button',
|
||||
defaultMessage: 'Next Up: {title}',
|
||||
description: 'Button to advance to the next section, with title',
|
||||
},
|
||||
previousButton: {
|
||||
id: 'learn.sequence.navigation.previous.button',
|
||||
defaultMessage: 'Previous',
|
||||
|
||||
@@ -6,10 +6,8 @@ Factory.define('courseMetadata')
|
||||
.extend(courseMetadataBase)
|
||||
.option('host', '')
|
||||
.attrs({
|
||||
can_show_upgrade_sock: false,
|
||||
content_type_gating_enabled: false,
|
||||
course_expired_message: null,
|
||||
effort: null,
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
@@ -34,7 +32,7 @@ Factory.define('courseMetadata')
|
||||
},
|
||||
show_calculator: false,
|
||||
license: 'all-rights-reserved',
|
||||
can_load_courseware: {
|
||||
course_access: {
|
||||
has_access: true,
|
||||
user_fragment: null,
|
||||
developer_message: null,
|
||||
@@ -60,4 +58,5 @@ Factory.define('courseMetadata')
|
||||
is_mfe_special_exams_enabled: false,
|
||||
is_mfe_proctored_exams_enabled: false,
|
||||
recommendations: null,
|
||||
mfe_short_url_is_active: true,
|
||||
});
|
||||
|
||||
@@ -1,37 +1,71 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('learningSequencesOutline')
|
||||
.option('courseId', (courseId) => {
|
||||
if (courseId) {
|
||||
return courseId;
|
||||
}
|
||||
throw new Error('courseId must be specified for learningSequencesOutline factory.');
|
||||
})
|
||||
.attrs({
|
||||
title: 'Demo Course',
|
||||
course_id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
outline: {
|
||||
sections: [],
|
||||
sequences: {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function buildEmptyOutline(courseId) {
|
||||
return Factory.build(
|
||||
'learningSequencesOutline',
|
||||
{},
|
||||
{ courseId },
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSimpleOutline(courseId, sequenceBlocks) {
|
||||
return Factory.build(
|
||||
'learningSequencesOutline',
|
||||
{
|
||||
title: 'Demo Course',
|
||||
course_id: courseId,
|
||||
outline: {
|
||||
sequences: Object.fromEntries(
|
||||
sequenceBlocks.map(({ id }) => [id, {}]),
|
||||
),
|
||||
sections: [],
|
||||
sequences: {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ courseId },
|
||||
);
|
||||
}
|
||||
|
||||
// Given courseBlocks (output from buildSimpleCourseBlocks), create a matching
|
||||
// Learning Sequences API outline (what the REST API would return to us).
|
||||
export function buildOutlineFromBlocks(courseBlocks) {
|
||||
const sections = {};
|
||||
const sequences = {};
|
||||
let courseBlock = null;
|
||||
|
||||
Object.values(courseBlocks.blocks).forEach(block => {
|
||||
if (block.type === 'course') {
|
||||
courseBlock = block;
|
||||
} else if (block.type === 'chapter') {
|
||||
sections[block.id] = {
|
||||
id: block.id,
|
||||
title: block.title,
|
||||
start: null,
|
||||
sequence_ids: [...block.children],
|
||||
};
|
||||
} else if (block.type === 'sequential') {
|
||||
sequences[block.id] = {
|
||||
id: block.id,
|
||||
title: block.title,
|
||||
accessible: true,
|
||||
start: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const outline = Factory.build(
|
||||
'learningSequencesOutline',
|
||||
{
|
||||
course_key: courseBlocks.courseId,
|
||||
title: courseBlocks.title,
|
||||
outline: {
|
||||
sections: courseBlock.children.map(sectionId => sections[sectionId]),
|
||||
sequences,
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return outline;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ Factory.define('sequenceMetadata')
|
||||
prereq_section_name: `${sequenceBlock.display_name}-prereq`,
|
||||
gated_section_name: sequenceBlock.display_name,
|
||||
}))
|
||||
|
||||
.attr('decoded_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.decoded_id)
|
||||
.attr('hash_key', ['sequenceBlock'], sequenceBlock => sequenceBlock.hash_key)
|
||||
.attr('items', ['unitBlocks', 'sequenceBlock'], (unitBlocks, sequenceBlock) => unitBlocks.map(
|
||||
unitBlock => ({
|
||||
href: '',
|
||||
@@ -44,10 +47,12 @@ Factory.define('sequenceMetadata')
|
||||
bookmarked: unitBlock.bookmarked || false,
|
||||
path: `Chapter Display Name > ${sequenceBlock.display_name} > ${unitBlock.display_name}`,
|
||||
type: unitBlock.type,
|
||||
hash_key: unitBlock.hash_key,
|
||||
complete: unitBlock.complete || null,
|
||||
content: '',
|
||||
page_title: unitBlock.display_name,
|
||||
contains_content_type_gated_content: unitBlock.contains_content_type_gated_content,
|
||||
decoded_id: unitBlock.decoded_id,
|
||||
}),
|
||||
))
|
||||
.attrs({
|
||||
@@ -68,7 +73,7 @@ Factory.define('sequenceMetadata')
|
||||
*/
|
||||
export default function buildSimpleCourseAndSequenceMetadata(options = {}) {
|
||||
const courseMetadata = options.courseMetadata || Factory.build('courseMetadata', {
|
||||
can_load_courseware: {
|
||||
course_access: {
|
||||
has_access: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,8 +16,6 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
models.courses[block.id] = {
|
||||
effortActivities: block.effort_activities,
|
||||
effortTime: block.effort_time,
|
||||
id: courseId,
|
||||
title: block.display_name,
|
||||
sectionIds: block.children || [],
|
||||
@@ -26,8 +24,6 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
break;
|
||||
case 'chapter':
|
||||
models.sections[block.id] = {
|
||||
effortActivities: block.effort_activities,
|
||||
effortTime: block.effort_time,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
sequenceIds: block.children || [],
|
||||
@@ -42,6 +38,7 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
title: block.display_name,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
unitIds: block.children || [],
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
case 'vertical':
|
||||
@@ -50,6 +47,7 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -91,6 +89,47 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
});
|
||||
}
|
||||
});
|
||||
return models;
|
||||
}
|
||||
|
||||
export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
|
||||
// Course
|
||||
const now = new Date();
|
||||
models.courses[learningSequencesData.course_key] = {
|
||||
id: learningSequencesData.course_key,
|
||||
title: learningSequencesData.title,
|
||||
sectionIds: learningSequencesData.outline.sections.map(section => section.id),
|
||||
|
||||
// Scan through all the sequences and look for ones that aren't accessible
|
||||
// to us yet because the start date has not yet passed. (Some may be
|
||||
// inaccessible because the end_date has passed.)
|
||||
hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(
|
||||
seq => !seq.accessible && now < Date.parse(seq.effective_start),
|
||||
),
|
||||
};
|
||||
|
||||
// Sections
|
||||
learningSequencesData.outline.sections.forEach(section => {
|
||||
models.sections[section.id] = {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
sequenceIds: section.sequence_ids,
|
||||
};
|
||||
});
|
||||
|
||||
// Sequences
|
||||
Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => {
|
||||
models.sequences[seqId] = {
|
||||
id: seqId,
|
||||
title: sequence.title,
|
||||
};
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
@@ -114,7 +153,7 @@ export async function getLearningSequencesOutline(courseId) {
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(outlineUrl.href, {});
|
||||
return data;
|
||||
return normalizeLearningSequencesData(data);
|
||||
} catch (error) {
|
||||
// This is not a critical API to use at the moment. If it errors for any
|
||||
// reason, just send back a null so the higher layers know to ignore it.
|
||||
@@ -160,7 +199,7 @@ function normalizeMetadata(metadata) {
|
||||
start: data.start,
|
||||
enrollmentMode: data.enrollment.mode,
|
||||
isEnrolled: data.enrollment.is_active,
|
||||
canLoadCourseware: camelCaseObject(data.can_load_courseware),
|
||||
courseAccess: camelCaseObject(data.course_access),
|
||||
canViewLegacyCourseware: data.can_view_legacy_courseware,
|
||||
originalUserIsStaff: data.original_user_is_staff,
|
||||
isStaff: data.is_staff,
|
||||
@@ -183,6 +222,8 @@ function normalizeMetadata(metadata) {
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
|
||||
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
|
||||
isMasquerading: data.original_user_is_staff && !data.is_staff,
|
||||
shortLinkFeatureFlag: data.mfe_short_url_is_active,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +260,8 @@ function normalizeSequenceMetadata(sequence) {
|
||||
saveUnitPosition: sequence.save_position,
|
||||
showCompletion: sequence.show_completion,
|
||||
allowProctoringOptOut: sequence.allow_proctoring_opt_out,
|
||||
hash_key: sequence.hash_key,
|
||||
decoded_id: sequence.decoded_id,
|
||||
},
|
||||
units: sequence.items.map(unit => ({
|
||||
id: unit.id,
|
||||
@@ -229,14 +272,14 @@ function normalizeSequenceMetadata(sequence) {
|
||||
contentType: unit.type,
|
||||
graded: unit.graded,
|
||||
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
|
||||
decoded_id: unit.decoded_id,
|
||||
hash_key: unit.hash_key,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSequenceMetadata(sequenceId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
|
||||
|
||||
return normalizeSequenceMetadata(data);
|
||||
}
|
||||
|
||||
|
||||
423
src/courseware/data/pact-tests/frontend-app-learning-lms.json
Normal file
423
src/courseware/data/pact-tests/frontend-app-learning-lms.json
Normal file
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"consumer": {
|
||||
"name": "frontend-app-learning"
|
||||
},
|
||||
"provider": {
|
||||
"name": "lms"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"description": "a request to get course blocks",
|
||||
"providerState": "Blocks data exists for course_id course-v1:edX+DemoX+Demo_Course",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/courses/v2/blocks/",
|
||||
"query": "course_id=course-v1%3AedX%2BDemoX%2BDemo_Course&username=Mock+User&depth=3&requested_fields=children%2Ceffort_activities%2Ceffort_time%2Cshow_gated_sections%2Cgraded%2Cspecial_exam_info%2Chas_scheduled_content"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
},
|
||||
"body": {
|
||||
"root": "block-v1:edX+DemoX+Demo_Course+type@course+block@course",
|
||||
"blocks": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@course": {
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@course+block@course",
|
||||
"block_id": "course",
|
||||
"lms_web_url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course",
|
||||
"legacy_web_url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course?experience=legacy",
|
||||
"student_view_url": "/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course",
|
||||
"type": "course",
|
||||
"display_name": "Demonstration Course"
|
||||
}
|
||||
}
|
||||
},
|
||||
"matchingRules": {
|
||||
"$.body.root": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.blocks": {
|
||||
"match": "type"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "a request to get course metadata",
|
||||
"providerState": "course metadata exists for course_id course-v1:edX+DemoX+Demo_Course",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/courseware/course/course-v1:edX+DemoX+Demo_Course",
|
||||
"query": "browser_timezone=Asia%2FKarachi"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
},
|
||||
"body": {
|
||||
"access_expiration": {
|
||||
"expiration_date": "2013-02-05T05:00:00Z",
|
||||
"masquerading_expired_course": false,
|
||||
"upgrade_deadline": "2013-02-05T05:00:00Z",
|
||||
"upgrade_url": "link"
|
||||
},
|
||||
"can_show_upgrade_sock": false,
|
||||
"content_type_gating_enabled": false,
|
||||
"end": "2013-02-05T05:00:00Z",
|
||||
"enrollment": {
|
||||
"mode": "audit",
|
||||
"is_active": true
|
||||
},
|
||||
"enrollment_start": "2013-02-05T05:00:00Z",
|
||||
"enrollment_end": "2013-02-05T05:00:00Z",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"license": "all-rights-reserved",
|
||||
"name": "Demonstration Course",
|
||||
"number": "DemoX",
|
||||
"offer": {
|
||||
"code": "code",
|
||||
"expiration_date": "2013-02-05T05:00:00Z",
|
||||
"original_price": "$99",
|
||||
"discounted_price": "$99",
|
||||
"percentage": 50,
|
||||
"upgrade_url": "url"
|
||||
},
|
||||
"org": "edX",
|
||||
"related_programs": null,
|
||||
"short_description": "",
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": [
|
||||
{
|
||||
"title": "Course",
|
||||
"slug": "courseware",
|
||||
"priority": 0,
|
||||
"type": "courseware",
|
||||
"url": "http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/home"
|
||||
}
|
||||
],
|
||||
"user_timezone": null,
|
||||
"verified_mode": {
|
||||
"access_expiration_date": null,
|
||||
"currency": "USD",
|
||||
"currency_symbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgrade_url": "http://localhost:18130/basket/add/?sku=8CF08E5"
|
||||
},
|
||||
"show_calculator": false,
|
||||
"original_user_is_staff": true,
|
||||
"can_view_legacy_courseware": true,
|
||||
"is_staff": true,
|
||||
"course_access": {
|
||||
"has_access": true,
|
||||
"error_code": null,
|
||||
"developer_message": null,
|
||||
"user_message": null,
|
||||
"additional_context_user_message": null,
|
||||
"user_fragment": null
|
||||
},
|
||||
"notes": {
|
||||
"enabled": false,
|
||||
"visible": true
|
||||
},
|
||||
"marketing_url": null,
|
||||
"celebrations": {
|
||||
"irst_section": false,
|
||||
"streak_length_to_celebrate": null,
|
||||
"streak_discount_experiment_enabled": false
|
||||
},
|
||||
"user_has_passing_grade": false,
|
||||
"course_exit_page_is_active": false,
|
||||
"certificate_data": {
|
||||
"cert_status": "audit_passing",
|
||||
"cert_web_view_url": null,
|
||||
"download_url": null,
|
||||
"certificate_available_date": null
|
||||
},
|
||||
"verify_identity_url": null,
|
||||
"verification_status": "none",
|
||||
"linkedin_add_to_profile_url": null,
|
||||
"is_mfe_special_exams_enabled": false,
|
||||
"is_mfe_proctored_exams_enabled": false,
|
||||
"user_needs_integrity_signature": false
|
||||
},
|
||||
"matchingRules": {
|
||||
"$.body.access_expiration.expiration_date": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.access_expiration.masquerading_expired_course": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.access_expiration.upgrade_deadline": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.access_expiration.upgrade_url": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.can_show_upgrade_sock": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.content_type_gating_enabled": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.end": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.enrollment.mode": {
|
||||
"match": "regex",
|
||||
"regex": "^(audit|verified)$"
|
||||
},
|
||||
"$.body.enrollment.is_active": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.enrollment_start": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.enrollment_end": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.id": {
|
||||
"match": "regex",
|
||||
"regex": "[\\w\\-~.:]"
|
||||
},
|
||||
"$.body.license": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.name": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.number": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.offer.code": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.offer.expiration_date": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.offer.original_price": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.offer.discounted_price": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.offer.percentage": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.offer.upgrade_url": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.org": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.short_description": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.start": {
|
||||
"match": "regex",
|
||||
"regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
|
||||
},
|
||||
"$.body.tabs": {
|
||||
"min": 1
|
||||
},
|
||||
"$.body.tabs[*].*": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.verified_mode": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.show_calculator": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.original_user_is_staff": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.can_view_legacy_courseware": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.is_staff": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.course_access": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.course_access.has_access": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.notes.enabled": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.notes.visible": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.celebrations.irst_section": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.celebrations.streak_discount_experiment_enabled": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.user_has_passing_grade": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.course_exit_page_is_active": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.certificate_data.cert_status": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.verification_status": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.is_mfe_special_exams_enabled": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.is_mfe_proctored_exams_enabled": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.user_needs_integrity_signature": {
|
||||
"match": "type"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "a request to get sequence metadata",
|
||||
"providerState": "sequence metadata data exists for sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/courseware/sequence/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
},
|
||||
"body": {
|
||||
"items": [
|
||||
{
|
||||
"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": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
|
||||
"is_time_limited": false,
|
||||
"is_proctored": false,
|
||||
"position": null,
|
||||
"tag": "sequential",
|
||||
"banner_text": null,
|
||||
"save_position": false,
|
||||
"show_completion": false,
|
||||
"gated_content": {
|
||||
"prereq_id": null,
|
||||
"prereq_url": null,
|
||||
"prereq_section_name": null,
|
||||
"gated": false,
|
||||
"gated_section_name": "Homework - Question Styles"
|
||||
},
|
||||
"display_name": "Homework - Question Styles",
|
||||
"format": "Homework"
|
||||
},
|
||||
"matchingRules": {
|
||||
"$.body.items": {
|
||||
"min": 1
|
||||
},
|
||||
"$.body.items[*].*": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.item_id": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.is_time_limited": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.is_proctored": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.tag": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.save_position": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.show_completion": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.gated_content": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.display_name": {
|
||||
"match": "type"
|
||||
},
|
||||
"$.body.format": {
|
||||
"match": "type"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "a request to set sequence position against activeUnitIndex",
|
||||
"providerState": "sequence position data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and activeUnitIndex 0",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions/handler/goto_position",
|
||||
"body": {
|
||||
"position": 1
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
},
|
||||
"body": {
|
||||
"success": true
|
||||
},
|
||||
"matchingRules": {
|
||||
"$.body.success": {
|
||||
"match": "type"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "a request to get completion block",
|
||||
"providerState": "completion block data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and usageId block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions/handler/get_completion",
|
||||
"body": {
|
||||
"usage_key": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
},
|
||||
"body": {
|
||||
"complete": true
|
||||
},
|
||||
"matchingRules": {
|
||||
"$.body.complete": {
|
||||
"match": "type"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"pactSpecification": {
|
||||
"version": "2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
437
src/courseware/data/pact-tests/lmsPact.test.jsx
Normal file
437
src/courseware/data/pact-tests/lmsPact.test.jsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import {
|
||||
getCourseBlocks, getCourseMetadata, getSequenceMetadata, postSequencePosition, getBlockCompletion,
|
||||
} from '../api';
|
||||
import { initializeMockApp } from '../../../setupTest';
|
||||
|
||||
const {
|
||||
somethingLike: like, term, boolean, string, eachLike, integer,
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
|
||||
dir: path.resolve(process.cwd(), 'src/courseware/data/pact-tests'),
|
||||
logLevel: 'DEBUG',
|
||||
cors: true,
|
||||
});
|
||||
|
||||
describe('Courseware Service', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions';
|
||||
const usageId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c';
|
||||
const dateRegex = '^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$';
|
||||
const opaqueKeysRegex = '[\\w\\-~.:]';
|
||||
let authenticatedUser;
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
authenticatedUser = getAuthenticatedUser();
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
|
||||
describe('When a request to get course blocks is made', () => {
|
||||
it('returns normalized course blocks', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Blocks data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get course blocks',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: '/api/courses/v2/blocks/',
|
||||
query: {
|
||||
course_id: courseId,
|
||||
username: authenticatedUser ? authenticatedUser.username : '',
|
||||
depth: '3',
|
||||
requested_fields: 'children,effort_activities,effort_time,show_gated_sections,graded,special_exam_info,has_scheduled_content',
|
||||
},
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body:
|
||||
{
|
||||
root: string('block-v1:edX+DemoX+Demo_Course+type@course+block@course'),
|
||||
blocks: like({
|
||||
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
block_id: 'course',
|
||||
lms_web_url: '/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
legacy_web_url: '/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course?experience=legacy',
|
||||
student_view_url: '/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
type: 'course',
|
||||
display_name: 'Demonstration Course',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedCourseBlock = {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
title: 'Demonstration Course',
|
||||
sectionIds: [],
|
||||
hasScheduledContent: false,
|
||||
},
|
||||
};
|
||||
const response = await getCourseBlocks(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response.courses).toEqual(normalizedCourseBlock);
|
||||
expect(response.sections).toEqual({});
|
||||
expect(response.sequences).toEqual({});
|
||||
expect(response.units).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
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'),
|
||||
number: like('DemoX'),
|
||||
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'),
|
||||
},
|
||||
org: like('edX'),
|
||||
related_programs: null,
|
||||
short_description: like(''),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
title: 'Course', slug: 'courseware', priority: 0, type: 'courseware', url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
user_timezone: null,
|
||||
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`,
|
||||
}),
|
||||
show_calculator: boolean(false),
|
||||
original_user_is_staff: boolean(true),
|
||||
can_view_legacy_courseware: boolean(true),
|
||||
is_staff: boolean(true),
|
||||
course_access: like({
|
||||
has_access: boolean(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,
|
||||
celebrations: {
|
||||
irst_section: boolean(false),
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_experiment_enabled: boolean(false),
|
||||
},
|
||||
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, download_url: null, certificate_available_date: null,
|
||||
},
|
||||
verify_identity_url: null,
|
||||
verification_status: string('none'),
|
||||
linkedin_add_to_profile_url: null,
|
||||
is_mfe_special_exams_enabled: boolean(false),
|
||||
is_mfe_proctored_exams_enabled: boolean(false),
|
||||
user_needs_integrity_signature: boolean(false),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedCourseMetadata = {
|
||||
accessExpiration: {
|
||||
expirationDate: '2013-02-05T05:00:00Z',
|
||||
masqueradingExpiredCourse: false,
|
||||
upgradeDeadline: '2013-02-05T05:00:00Z',
|
||||
upgradeUrl: 'link',
|
||||
},
|
||||
canShowUpgradeSock: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
title: 'Demonstration Course',
|
||||
number: 'DemoX',
|
||||
offer: {
|
||||
code: 'code',
|
||||
discountedPrice: '$99',
|
||||
expirationDate: '2013-02-05T05:00:00Z',
|
||||
originalPrice: '$99',
|
||||
percentage: 50,
|
||||
upgradeUrl: 'url',
|
||||
},
|
||||
org: 'edX',
|
||||
enrollmentStart: '2013-02-05T05:00:00Z',
|
||||
enrollmentEnd: '2013-02-05T05:00:00Z',
|
||||
end: '2013-02-05T05:00:00Z',
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
enrollmentMode: 'audit',
|
||||
isEnrolled: true,
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
canViewLegacyCourseware: true,
|
||||
originalUserIsStaff: true,
|
||||
isStaff: true,
|
||||
license: 'all-rights-reserved',
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
title: 'Course',
|
||||
slug: 'courseware',
|
||||
priority: 0,
|
||||
type: 'courseware',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
userTimezone: null,
|
||||
showCalculator: false,
|
||||
notes: { enabled: false, visible: true },
|
||||
marketingUrl: null,
|
||||
celebrations: {
|
||||
irstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountExperimentEnabled: false,
|
||||
},
|
||||
userHasPassingGrade: false,
|
||||
courseExitPageIsActive: false,
|
||||
certificateData: {
|
||||
certStatus: 'audit_passing',
|
||||
certWebViewUrl: null,
|
||||
downloadUrl: null,
|
||||
certificateAvailableDate: null,
|
||||
},
|
||||
timeOffsetMillis: 0,
|
||||
verifyIdentityUrl: null,
|
||||
verificationStatus: 'none',
|
||||
linkedinAddToProfileUrl: null,
|
||||
relatedPrograms: null,
|
||||
userNeedsIntegritySignature: false,
|
||||
specialExamsEnabledWaffleFlag: false,
|
||||
proctoredExamsEnabledWaffleFlag: false,
|
||||
isMasquerading: false,
|
||||
};
|
||||
const response = await getCourseMetadata(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedCourseMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get sequence metadata is made', () => {
|
||||
it('returns normalized sequence metadata ', 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),
|
||||
position: null,
|
||||
tag: boolean('sequential'),
|
||||
banner_text: null,
|
||||
save_position: boolean(false),
|
||||
show_completion: boolean(false),
|
||||
gated_content: like({
|
||||
prereq_id: null,
|
||||
prereq_url: null,
|
||||
prereq_section_name: null,
|
||||
gated: false,
|
||||
gated_section_name: 'Homework - Question Styles',
|
||||
}),
|
||||
display_name: boolean('Homework - Question Styles'),
|
||||
format: boolean('Homework'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedSequenceMetadata = {
|
||||
sequence: {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||
blockType: 'sequential',
|
||||
unitIds: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
],
|
||||
bannerText: null,
|
||||
format: 'Homework',
|
||||
title: 'Homework - Question Styles',
|
||||
gatedContent: {
|
||||
prereqId: null,
|
||||
prereqUrl: null,
|
||||
prereqSectionName: null,
|
||||
gated: false,
|
||||
gatedSectionName: 'Homework - Question Styles',
|
||||
},
|
||||
isTimeLimited: false,
|
||||
isProctored: false,
|
||||
activeUnitIndex: 0,
|
||||
saveUnitPosition: false,
|
||||
showCompletion: false,
|
||||
allowProctoringOptOut: undefined,
|
||||
},
|
||||
units: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||
bookmarked: false,
|
||||
complete: undefined,
|
||||
title: 'Pointing on a Picture',
|
||||
contentType: 'problem',
|
||||
graded: true,
|
||||
containsContentTypeGatedContent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const response = await getSequenceMetadata(sequenceId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedSequenceMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to set sequence position against Unit Index is made', () => {
|
||||
it('returns if the request was success or failure', async () => {
|
||||
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),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await postSequencePosition(courseId, sequenceId, 0);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get completion block is made', () => {
|
||||
it('returns the completion status', async () => {
|
||||
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),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await getBlockCompletion(courseId, sequenceId, usageId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import * as thunks from './thunks';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
|
||||
import { buildSimpleCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
|
||||
import { buildEmptyOutline, buildSimpleOutline } from './__factories__/learningSequencesOutline.factory';
|
||||
import { buildOutlineFromBlocks } from './__factories__/learningSequencesOutline.factory';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
@@ -32,8 +32,7 @@ describe('Data layer integration tests', () => {
|
||||
{},
|
||||
{ courseId, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
||||
);
|
||||
const emptyOutline = buildEmptyOutline(courseId);
|
||||
const simpleOutline = buildSimpleOutline(courseId, sequenceBlocks);
|
||||
const simpleOutline = buildOutlineFromBlocks(courseBlocks);
|
||||
|
||||
let courseUrl = `${courseBaseUrl}/${courseId}`;
|
||||
courseUrl = appendBrowserTimezoneToUrl(courseUrl);
|
||||
@@ -68,7 +67,7 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
it('Should fetch, normalize, and save metadata, but with denied status', async () => {
|
||||
const forbiddenCourseMetadata = Factory.build('courseMetadata', {
|
||||
can_load_courseware: {
|
||||
course_access: {
|
||||
has_access: false,
|
||||
},
|
||||
});
|
||||
@@ -89,7 +88,7 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.courseStatus).toEqual('denied');
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[forbiddenCourseMetadata.id].canLoadCourseware).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[forbiddenCourseMetadata.id].courseAccess).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
@@ -107,7 +106,7 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].canLoadCourseware).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[courseId].courseAccess).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata; filtering has no effect', async () => {
|
||||
@@ -127,8 +126,9 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].canLoadCourseware).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[courseId].courseAccess).not.toBeUndefined();
|
||||
expect(state.models.sequences.length === 1);
|
||||
|
||||
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 1));
|
||||
});
|
||||
|
||||
@@ -137,8 +137,12 @@ describe('Data layer integration tests', () => {
|
||||
// (even though it won't actually filter down in this case).
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, emptyOutline);
|
||||
|
||||
// Create an outline with basic matching metadata, but then empty it out...
|
||||
const emptyOutline = buildOutlineFromBlocks(courseBlocks);
|
||||
emptyOutline.sequences = {};
|
||||
emptyOutline.sections = [];
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, emptyOutline);
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
@@ -149,8 +153,9 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].canLoadCourseware).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[courseId].courseAccess).not.toBeUndefined();
|
||||
expect(state.models.sequences === null);
|
||||
|
||||
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 0));
|
||||
});
|
||||
});
|
||||
@@ -249,8 +254,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test checkBlockCompletion', () => {
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`;
|
||||
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceMetadata.decoded_id}/handler/get_completion`;
|
||||
it('Should fail to check completion and log error', async () => {
|
||||
axiosMock.onPost(getCompletionURL).networkError();
|
||||
|
||||
@@ -278,7 +282,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test saveSequencePosition', () => {
|
||||
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`;
|
||||
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceMetadata.decoded_id}/handler/goto_position`;
|
||||
|
||||
it('Should change and revert sequence model activeUnitIndex in case of error', async () => {
|
||||
axiosMock.onPost(gotoPositionURL).networkError();
|
||||
|
||||
@@ -15,6 +15,7 @@ const slice = createSlice({
|
||||
sequenceId: null,
|
||||
specialExamsEnabledWaffleFlag: false,
|
||||
proctoredExamsEnabledWaffleFlag: false,
|
||||
shortLinkFeatureFlag: false,
|
||||
},
|
||||
reducers: {
|
||||
setsSpecialExamsEnabled: (state, { payload }) => {
|
||||
@@ -23,6 +24,9 @@ const slice = createSlice({
|
||||
setsProctoredExamsEnabled: (state, { payload }) => {
|
||||
state.proctoredExamsEnabledWaffleFlag = payload.proctoredExamsEnabledWaffleFlag;
|
||||
},
|
||||
setsShortLinkFeatureFlag: (state, { payload }) => {
|
||||
state.shortLinkFeatureFlag = payload.shortLinkFeatureFlag;
|
||||
},
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
@@ -57,6 +61,7 @@ const slice = createSlice({
|
||||
export const {
|
||||
setsSpecialExamsEnabled,
|
||||
setsProctoredExamsEnabled,
|
||||
setsShortLinkFeatureFlag,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
setsSpecialExamsEnabled,
|
||||
setsProctoredExamsEnabled,
|
||||
setsShortLinkFeatureFlag,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
@@ -23,17 +24,114 @@ import {
|
||||
fetchSequenceFailure,
|
||||
} from './slice';
|
||||
|
||||
// Make a copy of the sectionData and return it, but with the sequences filtered
|
||||
// down to only those sequences in allowedSequences
|
||||
function filterSequencesFromSection(sectionData, allowedSequences) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(sectionData).map(
|
||||
([key, value]) => [
|
||||
key,
|
||||
(key === 'sequenceIds') ? value.filter(seqId => seqId in allowedSequences) : value,
|
||||
],
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Combines the models from the Course Blocks and Learning Sequences API into a
|
||||
* new models obj that is returned. Does not mutate the models passed in.
|
||||
*
|
||||
* For performance and long term maintainability, we want to switch as much of
|
||||
* the Courseware MFE to use the Learning Sequences API as possible, and
|
||||
* eventually remove calls to the Course Blocks API. However, right now, certain
|
||||
* data still has to come form the Course Blocks API. This function is a
|
||||
* transitional step to help build out some of the data from the new API, while
|
||||
* falling back to the Course Blocks API for other things.
|
||||
*
|
||||
* Overall performance gains will not be realized until we completely remove
|
||||
* this call to the Course Blocks API (and the need for this function).
|
||||
*
|
||||
* @param {*} learningSequencesModels Normalized model from normalizeLearningSequencesData
|
||||
* @param {*} courseBlocksModels Normalized model from normalizeBlocks
|
||||
* @param {bool} isMasquerading Is Masquerading being used?
|
||||
*/
|
||||
function mergeLearningSequencesWithCourseBlocks(learningSequencesModels, courseBlocksModels, isMasquerading) {
|
||||
// If there's no Learning Sequences API data yet (not active for this course),
|
||||
// send back the course blocks model as-is. Likewise, Learning Sequences
|
||||
// doesn't currently handle masquerading properly for content groups.
|
||||
if (isMasquerading || learningSequencesModels === null) {
|
||||
return courseBlocksModels;
|
||||
}
|
||||
const mergedModels = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
|
||||
// Units are now copied over verbatim from Course Blocks API, but they
|
||||
// should eventually come just-in-time, once the Sequence Metadata API is
|
||||
// made to be acceptably fast.
|
||||
units: courseBlocksModels.units,
|
||||
};
|
||||
|
||||
// Top level course information
|
||||
//
|
||||
// It is not at all clear to me why courses is a dict when there's only ever
|
||||
// one course, but I'm not going to make that model change right now.
|
||||
const lsCourse = Object.values(learningSequencesModels.courses)[0];
|
||||
const [courseBlockId, courseBlock] = Object.entries(courseBlocksModels.courses)[0];
|
||||
|
||||
// The Learning Sequences API never exposes the usage key of the root course
|
||||
// block, which is used as the key here (instead of the CourseKey). It doesn't
|
||||
// look like anything actually queries for this value though, and even the
|
||||
// courseBlocksModels.courses uses the CourseKey as the "id" in the value. So
|
||||
// I'm imitating the form here to minimize the chance of things breaking, but
|
||||
// I think we should just forget the keys and replace courses with a singular
|
||||
// course. I might end up doing that before my refactoring is done here. >_<
|
||||
mergedModels.courses[courseBlockId] = {
|
||||
// Learning Sequences API Data
|
||||
id: lsCourse.id,
|
||||
title: lsCourse.title,
|
||||
sectionIds: lsCourse.sectionIds,
|
||||
hasScheduledContent: lsCourse.hasScheduledContent,
|
||||
|
||||
// Still pulling from Course Blocks API
|
||||
effortActivities: courseBlock.effortActivities,
|
||||
effortTime: courseBlock.effortTime,
|
||||
};
|
||||
|
||||
// List of Sequences comes from Learning Sequences. Course Blocks will have
|
||||
// extra sequences that we don't want to display to the user, like ones that
|
||||
// are empty because all the enclosed units are in user partition groups that
|
||||
// the user is not a part of (e.g. Verified Track).
|
||||
Object.entries(learningSequencesModels.sequences).forEach(([sequenceId, sequence]) => {
|
||||
const blocksSequence = courseBlocksModels.sequences[sequenceId];
|
||||
mergedModels.sequences[sequenceId] = {
|
||||
// Learning Sequences API Data
|
||||
id: sequenceId,
|
||||
title: sequence.title,
|
||||
|
||||
// Still pulling from Course Blocks API Data:
|
||||
effortActivities: blocksSequence.effortActivities,
|
||||
effortTime: blocksSequence.effortTime,
|
||||
legacyWebUrl: blocksSequence.legacyWebUrl,
|
||||
hash_key: blocksSequence.hash_key,
|
||||
unitIds: blocksSequence.unitIds,
|
||||
};
|
||||
|
||||
// Add back-references to this sequence for all child units.
|
||||
blocksSequence.unitIds.forEach(childUnitId => {
|
||||
mergedModels.units[childUnitId].sequenceId = sequenceId;
|
||||
});
|
||||
});
|
||||
|
||||
// List of Sections comes from Learning Sequences.
|
||||
Object.entries(learningSequencesModels.sections).forEach(([sectionId, section]) => {
|
||||
const blocksSection = courseBlocksModels.sections[sectionId];
|
||||
mergedModels.sections[sectionId] = {
|
||||
// Learning Sequences API Data
|
||||
id: sectionId,
|
||||
title: section.title,
|
||||
sequenceIds: section.sequenceIds,
|
||||
courseId: lsCourse.id,
|
||||
|
||||
// Still pulling from Course Blocks API Data:
|
||||
effortActivities: blocksSection.effortActivities,
|
||||
effortTime: blocksSection.effortTime,
|
||||
};
|
||||
// Add back-references to this section for all child sequences.
|
||||
section.sequenceIds.forEach(childSeqId => {
|
||||
mergedModels.sequences[childSeqId].sectionId = sectionId;
|
||||
});
|
||||
});
|
||||
|
||||
return mergedModels;
|
||||
}
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
@@ -55,34 +153,19 @@ export function fetchCourse(courseId) {
|
||||
dispatch(setsProctoredExamsEnabled({
|
||||
proctoredExamsEnabledWaffleFlag: courseMetadataResult.value.proctoredExamsEnabledWaffleFlag,
|
||||
}));
|
||||
dispatch(setsShortLinkFeatureFlag({
|
||||
shortLinkFeatureFlag: courseMetadataResult.value.shortLinkFeatureFlag,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseBlocksResult.status === 'fulfilled') {
|
||||
const {
|
||||
courses, sections, sequences, units,
|
||||
} = courseBlocksResult.value;
|
||||
|
||||
// Filter the data we get from the Course Blocks API using the data we
|
||||
// get back from the Learning Sequences API (which knows to hide certain
|
||||
// sequences that users shouldn't see).
|
||||
//
|
||||
// This is temporary – all this data should come from Learning Sequences
|
||||
// soon.
|
||||
let filteredSections = sections;
|
||||
let filteredSequences = sequences;
|
||||
if (learningSequencesOutlineResult.value) {
|
||||
const allowedSequences = learningSequencesOutlineResult.value.outline.sequences;
|
||||
filteredSequences = Object.fromEntries(
|
||||
Object.entries(sequences).filter(
|
||||
([blockId]) => blockId in allowedSequences,
|
||||
),
|
||||
);
|
||||
filteredSections = Object.fromEntries(
|
||||
Object.entries(sections).map(
|
||||
([blockId, sectionData]) => [blockId, filterSequencesFromSection(sectionData, allowedSequences)],
|
||||
),
|
||||
);
|
||||
}
|
||||
} = mergeLearningSequencesWithCourseBlocks(
|
||||
learningSequencesOutlineResult.value,
|
||||
courseBlocksResult.value,
|
||||
courseMetadataResult.value.isMasquerading,
|
||||
);
|
||||
|
||||
// This updates the course with a sectionIds array from the blocks data.
|
||||
dispatch(updateModelsMap({
|
||||
@@ -91,17 +174,25 @@ export function fetchCourse(courseId) {
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sections',
|
||||
modelsMap: filteredSections,
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences and units because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: filteredSequences,
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sequenceIdToHashKeyMap',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
modelsMap: units,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'unitIdToHashKeyMap',
|
||||
modelsMap: units,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
@@ -117,7 +208,7 @@ export function fetchCourse(courseId) {
|
||||
}
|
||||
|
||||
if (fetchedMetadata) {
|
||||
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
|
||||
if (courseMetadataResult.value.courseAccess.hasAccess && fetchedBlocks) {
|
||||
// User has access
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
return;
|
||||
@@ -153,10 +244,18 @@ export function fetchSequence(sequenceId) {
|
||||
modelType: 'sequences',
|
||||
model: sequence,
|
||||
}));
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequenceIdToHashKeyMap',
|
||||
model: sequence,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'units',
|
||||
models: units,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'unitIdToHashKeyMap',
|
||||
models: units,
|
||||
}));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -169,16 +268,24 @@ export function fetchSequence(sequenceId) {
|
||||
export function checkBlockCompletion(courseId, sequenceId, unitId) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
if (models.units[unitId].complete) {
|
||||
let modelsUnitId = unitId;
|
||||
let modelsSequenceId = sequenceId;
|
||||
if (!models.units[unitId]) {
|
||||
modelsUnitId = models.unitIdToHashKeyMap[unitId];
|
||||
}
|
||||
if (!models.sequences[sequenceId]) {
|
||||
modelsSequenceId = models.sequenceIdToHashKeyMap[sequenceId];
|
||||
}
|
||||
if (models.units[modelsUnitId].complete) {
|
||||
return; // do nothing. Things don't get uncompleted after they are completed.
|
||||
}
|
||||
|
||||
try {
|
||||
const isComplete = await getBlockCompletion(courseId, sequenceId, unitId);
|
||||
const isComplete = await getBlockCompletion(courseId, modelsSequenceId, modelsUnitId);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
id: modelsUnitId,
|
||||
complete: isComplete,
|
||||
},
|
||||
}));
|
||||
@@ -191,23 +298,27 @@ export function checkBlockCompletion(courseId, sequenceId, unitId) {
|
||||
export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
const initialActiveUnitIndex = models.sequences[sequenceId].activeUnitIndex;
|
||||
let modelsSequenceId = sequenceId;
|
||||
if (!models.sequences[sequenceId]) {
|
||||
modelsSequenceId = models.sequenceIdToHashKeyMap[sequenceId];
|
||||
}
|
||||
const initialActiveUnitIndex = models.sequences[modelsSequenceId].activeUnitIndex;
|
||||
// Optimistically update the position.
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
id: modelsSequenceId,
|
||||
activeUnitIndex,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
await postSequencePosition(courseId, sequenceId, activeUnitIndex);
|
||||
await postSequencePosition(courseId, modelsSequenceId, activeUnitIndex);
|
||||
// Update again under the assumption that the above call succeeded, since it doesn't return a
|
||||
// meaningful response.
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
id: modelsSequenceId,
|
||||
activeUnitIndex,
|
||||
},
|
||||
}));
|
||||
@@ -216,7 +327,7 @@ export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
id: modelsSequenceId,
|
||||
activeUnitIndex: initialActiveUnitIndex,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import LearnerQuote1 from './assets/learner-quote.png';
|
||||
import LearnerQuote2 from './assets/learner-quote2.png';
|
||||
import { UpgradeButton } from '../upgrade-button';
|
||||
import VerifiedCert from '../assets/edX_certificate.png';
|
||||
|
||||
export default class CourseSock extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { showUpsell: false };
|
||||
this.sockElement = React.createRef();
|
||||
this.commonEventProperties = {
|
||||
courserun_key: this.props.courseId,
|
||||
org_key: this.props.orgKey,
|
||||
};
|
||||
this.promotionEventProperties = {
|
||||
creative: 'original_sock',
|
||||
name: 'In-Course Verification Prompt',
|
||||
position: 'sock',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
...this.commonEventProperties,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
sendTrackEvent('Promotion Viewed', this.promotionEventProperties);
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
this.setState(state => ({
|
||||
showUpsell: !state.showUpsell,
|
||||
}));
|
||||
|
||||
const toggleLogEvent = this.state.showUpsell ? 'edx.bi.course.sock.toggle_opened'
|
||||
: 'edx.bi.course.sock.toggle_closed';
|
||||
sendTrackEvent(toggleLogEvent, {
|
||||
from_page: this.props.pageLocation,
|
||||
...this.commonEventProperties,
|
||||
});
|
||||
sendTrackEvent('Promotion Clicked', this.promotionEventProperties);
|
||||
}
|
||||
|
||||
logClick = () => {
|
||||
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
|
||||
location: 'sock',
|
||||
...this.commonEventProperties,
|
||||
});
|
||||
const onCourseHome = this.props.pageLocation === 'Home Page';
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...this.commonEventProperties,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: onCourseHome ? 'course_home_sock' : 'in_course_sock',
|
||||
linkType: 'button',
|
||||
pageName: onCourseHome ? 'course_home' : 'in_course',
|
||||
});
|
||||
}
|
||||
|
||||
showToUser = () => {
|
||||
this.setState({
|
||||
showUpsell: true,
|
||||
}, () => {
|
||||
if (this.sockElement.current) {
|
||||
this.sockElement.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonClass = this.state.showUpsell ? 'btn-primary' : 'btn-outline-primary';
|
||||
return (
|
||||
<div ref={this.sockElement} className="verification-sock container py-5">
|
||||
<div className="d-flex justify-content-center">
|
||||
<button type="button" aria-expanded="false" className={`btn ${buttonClass}`} onClick={this.handleClick}>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.heading"
|
||||
defaultMessage="Learn About Verified Certificates"
|
||||
description="The heading for the upsell dialog"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{this.state.showUpsell && (
|
||||
<>
|
||||
<h2 className="mt-3 mb-4">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.verifiedcert"
|
||||
defaultMessage="{siteName} Verified Certificate"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
<div className="row flex-row-reverse">
|
||||
<div className="col-md-4 col-lg-6 d-flex flex-column">
|
||||
<div>
|
||||
<img alt="Example Certificate" src={VerifiedCert} className="d-block img-thumbnail mb-3 ml-md-auto" />
|
||||
</div>
|
||||
<div className="position-relative flex-grow-1 d-flex flex-column justify-content-end align-items-md-end">
|
||||
<div style={{ position: 'sticky', bottom: '4rem' }}>
|
||||
<UpgradeButton
|
||||
size="lg"
|
||||
offer={this.props.offer}
|
||||
onClick={this.logClick}
|
||||
verifiedMode={this.props.verifiedMode}
|
||||
className="mb-3"
|
||||
data-creative="original_sock"
|
||||
data-position="sock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-8 col-lg-6">
|
||||
<h3 className="h5">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.why"
|
||||
defaultMessage="Why upgrade?"
|
||||
/>
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason1"
|
||||
defaultMessage="Official proof of completion"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason2"
|
||||
defaultMessage="Easily shareable certificate"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason3"
|
||||
defaultMessage="Proven motivator to complete the course"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason4"
|
||||
defaultMessage="Certificate purchases help {siteName} continue to offer free courses"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 className="h5">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.howtitle"
|
||||
defaultMessage="How it works"
|
||||
/>
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how1"
|
||||
defaultMessage="Pay the Verified Certificate upgrade fee"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how2"
|
||||
defaultMessage="Verify your identity with a webcam and government-issued ID"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how3"
|
||||
defaultMessage="Study hard and pass the course"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how4"
|
||||
defaultMessage="Share your certificate with friends, employers, and others"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="h5">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.storytitle"
|
||||
defaultMessage="{siteName} Learner Stories"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</h3>
|
||||
<div className="media my-3">
|
||||
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
|
||||
<div className="media-body">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.story1"
|
||||
defaultMessage="My certificate has helped me showcase my knowledge on my
|
||||
resume - I feel like this certificate could really help me land
|
||||
my dream job!"
|
||||
/>
|
||||
<p className="font-weight-bold">
|
||||
— <FormattedMessage
|
||||
id="coursesock.upsell.learner"
|
||||
description="Name of learner"
|
||||
defaultMessage="{name}, {siteName} Learner"
|
||||
values={{
|
||||
name: 'Christina Fong',
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="media my-3">
|
||||
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
|
||||
<div className="media-body">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.story2"
|
||||
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
|
||||
illustrate that I am working towards this goal I have and that I have
|
||||
achieved something while I was unemployed."
|
||||
/>
|
||||
<p className="font-weight-bold">
|
||||
— <FormattedMessage
|
||||
id="coursesock.upsell.learner"
|
||||
description="Name of learner"
|
||||
defaultMessage="{name}, {siteName} Learner"
|
||||
values={{
|
||||
name: 'Cheryl Troell',
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CourseSock.defaultProps = {
|
||||
courseId: null,
|
||||
offer: null,
|
||||
orgKey: null,
|
||||
};
|
||||
|
||||
CourseSock.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
offer: PropTypes.shape({}),
|
||||
orgKey: PropTypes.string,
|
||||
pageLocation: PropTypes.string.isRequired,
|
||||
verifiedMode: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
render, screen, fireEvent, initializeMockApp, initializeTestStore,
|
||||
} from '../../setupTest';
|
||||
import CourseSock from './CourseSock';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Course Sock', () => {
|
||||
let store;
|
||||
const mockData = {
|
||||
verifiedMode: {
|
||||
upgradeUrl: 'test-url',
|
||||
price: 1234,
|
||||
currency: 'dollars',
|
||||
currencySymbol: '$',
|
||||
},
|
||||
pageLocation: 'Course Content Page',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
|
||||
await initializeMockApp();
|
||||
store = await initializeTestStore();
|
||||
const { courseware } = store.getState();
|
||||
mockData.courseId = courseware.courseId;
|
||||
});
|
||||
|
||||
it('hides upsell information on load', () => {
|
||||
render(<CourseSock {...mockData} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('edX Verified Certificate')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.only('handles click', () => {
|
||||
sendTrackEvent.mockClear();
|
||||
render(<CourseSock {...mockData} />);
|
||||
const learnMoreButton = screen.getByRole('button', { name: 'Learn About Verified Certificates' });
|
||||
fireEvent.click(learnMoreButton);
|
||||
|
||||
expect(screen.getByText('edX Verified Certificate')).toBeInTheDocument();
|
||||
const { currencySymbol, price } = mockData.verifiedMode;
|
||||
const upsellButton = screen.getByText(`Upgrade for ${currencySymbol}${price}`);
|
||||
expect(upsellButton).toBeInTheDocument();
|
||||
fireEvent.click(upsellButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
|
||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course_1',
|
||||
creative: 'original_sock',
|
||||
name: 'In-Course Verification Prompt',
|
||||
org_key: null,
|
||||
position: 'sock',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.course.sock.toggle_closed', {
|
||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course_1',
|
||||
from_page: 'Course Content Page',
|
||||
org_key: null,
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Clicked', {
|
||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course_1',
|
||||
creative: 'original_sock',
|
||||
name: 'In-Course Verification Prompt',
|
||||
org_key: null,
|
||||
position: 'sock',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: null,
|
||||
courserun_key: mockData.courseId,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: 'in_course_sock',
|
||||
linkType: 'button',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
fireEvent.click(learnMoreButton);
|
||||
expect(screen.queryByText('edX Verified Certificate')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user