Compare commits
170 Commits
AA-820b
...
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 | ||
|
|
915f521976 | ||
|
|
903d8d4cfb | ||
|
|
f2f4f5f3a5 | ||
|
|
28d359e715 | ||
|
|
d93df0e06f | ||
|
|
19f318679f | ||
|
|
d38c07a206 | ||
|
|
3b2bbbdbc4 | ||
|
|
832107f084 | ||
|
|
b23a6330f1 | ||
|
|
8970352cdd | ||
|
|
86a4cf9af7 | ||
|
|
e423dddb03 | ||
|
|
f21dad95b5 | ||
|
|
9978ddf418 | ||
|
|
d2a8d870af | ||
|
|
3ef4daecce | ||
|
|
d2573a16b1 | ||
|
|
e7c0ebdfe3 | ||
|
|
1ad2cf73bf | ||
|
|
d60d782e5a | ||
|
|
83151d291c | ||
|
|
ed55920f99 | ||
|
|
4f1c8a4671 | ||
|
|
0878bf9f13 | ||
|
|
21f3875dae | ||
|
|
d4dc75c5a0 | ||
|
|
8a1151e8c5 | ||
|
|
5e925c93da | ||
|
|
43033ddc91 | ||
|
|
cf08fa5eb9 | ||
|
|
37ce01c00a | ||
|
|
9edac2519a | ||
|
|
f9fbc1eb49 | ||
|
|
5c68c1d554 | ||
|
|
4180a2e7a0 | ||
|
|
554e63d653 | ||
|
|
c9f299eada | ||
|
|
d1bb46eef3 | ||
|
|
492c573f56 | ||
|
|
0c55863a3d | ||
|
|
fbd9d858e4 | ||
|
|
7b0429f472 | ||
|
|
56decd8ed0 | ||
|
|
3155055276 |
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.
|
||||
15843
package-lock.json
generated
15843
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.4",
|
||||
"@edx/frontend-enterprise": "4.2.3",
|
||||
"@edx/frontend-lib-special-exams": "1.0.0",
|
||||
"@edx/frontend-platform": "1.11.0",
|
||||
"@edx/paragon": "14.8.0",
|
||||
"@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,11 +4,12 @@ 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';
|
||||
|
||||
function AccessExpirationAlert({ intl, payload }) {
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -42,24 +43,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
|
||||
if (masqueradingExpiredCourse) {
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.expired"
|
||||
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationExpiredDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
<AccessExpirationAlertMasquerade payload={payload} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,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>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
function AccessExpirationAlertMasquerade({ payload }) {
|
||||
const {
|
||||
accessExpiration,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
masqueradingExpiredCourse,
|
||||
} = accessExpiration;
|
||||
|
||||
if (!masqueradingExpiredCourse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<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}."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationExpiredDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlertMasquerade.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlertMasquerade;
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
const AccessExpirationAlertMasquerade = React.lazy(() => import('./AccessExpirationAlertMasquerade'));
|
||||
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
@@ -22,4 +23,20 @@ function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone,
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
export function useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, topic) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
const payload = {
|
||||
accessExpiration,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlertMasquerade',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic,
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlertMasquerade: AccessExpirationAlertMasquerade };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from './hooks';
|
||||
export { default, useAccessExpirationAlertMasquerade } from './hooks';
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import { FormattedPricing } from '../../generic/upgrade-button';
|
||||
import messages from './messages';
|
||||
|
||||
function OfferAlert({ intl, payload }) {
|
||||
const {
|
||||
analyticsPageName,
|
||||
courseId,
|
||||
offer,
|
||||
org,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
if (!offer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
code,
|
||||
expirationDate,
|
||||
percentage,
|
||||
upgradeUrl,
|
||||
} = offer;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'welcome',
|
||||
linkName: `${analyticsPageName}_welcome`,
|
||||
linkType: 'link',
|
||||
pageName: analyticsPageName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.offer.header"
|
||||
defaultMessage="Upgrade by {date} and save {percentage}% [{fullPricing}]"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="offerDate"
|
||||
day="numeric"
|
||||
month="long"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
fullPricing: <FormattedPricing offer={offer} />,
|
||||
percentage,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.offer.code"
|
||||
defaultMessage="Use code {code} at checkout!"
|
||||
values={{
|
||||
code: (<b>{code}</b>),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
OfferAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
offer: PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
discountedPrice: PropTypes.string.isRequired,
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
originalPrice: PropTypes.string.isRequired,
|
||||
percentage: PropTypes.number.isRequired,
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
analyticsPageName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OfferAlert);
|
||||
@@ -1,25 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
|
||||
export function useOfferAlert(courseId, offer, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!offer; // if it exists, show it.
|
||||
const payload = {
|
||||
analyticsPageName,
|
||||
courseId,
|
||||
offer,
|
||||
org,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientOfferAlert',
|
||||
topic,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
});
|
||||
|
||||
return { clientOfferAlert: OfferAlert };
|
||||
}
|
||||
|
||||
export default useOfferAlert;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.offer.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -2,4 +2,4 @@ import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
import './progressTabData.factory';
|
||||
import './upgradeCardData.factory';
|
||||
import './upgradeNotificationData.factory';
|
||||
|
||||
@@ -29,6 +29,7 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
cert_data: {
|
||||
@@ -65,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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('upgradeCardData')
|
||||
Factory.define('upgradeNotificationData')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
@@ -8,6 +8,7 @@ Factory.define('upgradeCardData')
|
||||
.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,6 +5,7 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -15,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,
|
||||
@@ -28,6 +38,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -300,6 +311,7 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -310,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,
|
||||
@@ -323,6 +344,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -376,8 +398,7 @@ 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 [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
@@ -389,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 [
|
||||
@@ -404,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",
|
||||
@@ -444,15 +464,19 @@ Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"enrollmentMode": undefined,
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"offer": null,
|
||||
"resumeCourse": 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",
|
||||
@@ -476,6 +500,7 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -486,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,
|
||||
@@ -499,6 +533,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -556,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 {
|
||||
@@ -582,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",
|
||||
@@ -601,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,19 +111,16 @@ 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 || [],
|
||||
hasScheduledContent: block.has_scheduled_content,
|
||||
};
|
||||
break;
|
||||
|
||||
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,
|
||||
@@ -147,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;
|
||||
|
||||
@@ -202,17 +200,26 @@ export async function getDatesTabData(courseId) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return {};
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
// 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 getProgressTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
export async function getProgressTabData(courseId, targetUserId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
|
||||
// If targetUserId is passed in, we will get the progress page data
|
||||
// for the user with the provided id, rather than the requesting user.
|
||||
if (targetUserId) {
|
||||
url += `/${targetUserId}/`;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
@@ -237,22 +244,44 @@ export async function getProgressTabData(courseId) {
|
||||
// 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) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
// 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)}`;
|
||||
}
|
||||
@@ -315,13 +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,
|
||||
@@ -333,13 +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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,20 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
const progressTabData = Factory.build('progressTabData', { courseId });
|
||||
const targetUserId = 2;
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.targetUserId).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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',
|
||||
@@ -15,18 +16,23 @@ const slice = createSlice({
|
||||
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;
|
||||
},
|
||||
fetchTabSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.targetUserId = payload.targetUserId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
setCallToActionToast: (state, { payload }) => {
|
||||
const {
|
||||
header,
|
||||
@@ -41,9 +47,10 @@ const slice = createSlice({
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
setCallToActionToast,
|
||||
} = slice.actions;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
@@ -27,12 +28,12 @@ const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData) {
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
@@ -61,8 +62,11 @@ export function fetchTab(courseId, tab, getTabData) {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId }));
|
||||
// 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 }));
|
||||
}
|
||||
@@ -74,8 +78,8 @@ export function fetchDatesTab(courseId) {
|
||||
return fetchTab(courseId, 'dates', getDatesTabData);
|
||||
}
|
||||
|
||||
export function fetchProgressTab(courseId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData);
|
||||
export function fetchProgressTab(courseId, targetUserId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
|
||||
}
|
||||
|
||||
export function fetchOutlineTab(courseId) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function DatesBanner(props) {
|
||||
const {
|
||||
intl,
|
||||
name,
|
||||
bannerClickHandler,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
|
||||
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
|
||||
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-lg-9'}>
|
||||
<strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
|
||||
</strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
|
||||
</div>
|
||||
{bannerClickHandler && (
|
||||
<div className="col-auto col-lg-3 p-lg-0 d-inline-flex align-items-center justify-content-start justify-content-lg-center">
|
||||
<Button variant="outline-primary" className="align-self-center mt-3 mt-lg-0" onClick={bannerClickHandler}>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
bannerClickHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
DatesBanner.defaultProps = {
|
||||
bannerClickHandler: null,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesBanner);
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import DatesBanner from './DatesBanner';
|
||||
import { resetDeadlines } from '../data';
|
||||
|
||||
function DatesBannerContainer({
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
logUpgradeLinkClick,
|
||||
model,
|
||||
tabFetch,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
|
||||
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
|
||||
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
|
||||
const datesBanners = [
|
||||
{
|
||||
name: 'datesTabInfoBanner',
|
||||
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
|
||||
},
|
||||
{
|
||||
name: 'upgradeToCompleteGradedBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToCompleteGraded && verifiedUpgradeLink,
|
||||
clickHandler: () => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'upgradeToResetBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
|
||||
clickHandler: () => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resetDatesBanner',
|
||||
shouldDisplay: resetDates,
|
||||
clickHandler: () => dispatch(resetDeadlines(courseId, model, tabFetch)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
|
||||
<DatesBanner
|
||||
name={banner.name}
|
||||
bannerClickHandler={banner.clickHandler}
|
||||
key={banner.name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBannerContainer.propTypes = {
|
||||
courseDateBlocks: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
datesBannerInfo: PropTypes.shape({
|
||||
contentTypeGatingEnabled: PropTypes.bool.isRequired,
|
||||
missedDeadlines: PropTypes.bool.isRequired,
|
||||
missedGatedContent: PropTypes.bool.isRequired,
|
||||
verifiedUpgradeLink: PropTypes.string,
|
||||
}).isRequired,
|
||||
hasEnded: PropTypes.bool,
|
||||
logUpgradeLinkClick: PropTypes.func,
|
||||
model: PropTypes.string.isRequired,
|
||||
tabFetch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
DatesBannerContainer.defaultProps = {
|
||||
hasEnded: false,
|
||||
logUpgradeLinkClick: () => {},
|
||||
};
|
||||
|
||||
export default DatesBannerContainer;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DatesBannerContainer from './DatesBannerContainer';
|
||||
|
||||
export default DatesBannerContainer;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'datesBanner.datesTabInfoBanner.header': {
|
||||
id: 'datesBanner.datesTabInfoBanner.header',
|
||||
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
|
||||
description: 'Strong text in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.datesTabInfoBanner.body': {
|
||||
id: 'datesBanner.datesTabInfoBanner.body',
|
||||
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
|
||||
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
|
||||
description: 'Body in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.header': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.body': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
|
||||
assignments as part of this course, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.button': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'Button in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.header': {
|
||||
id: 'datesBanner.upgradeToResetBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.body': {
|
||||
id: 'datesBanner.upgradeToResetBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
|
||||
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
|
||||
and shift the past due assignments into the future, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.button': {
|
||||
id: 'datesBanner.upgradeToResetBanner.button',
|
||||
defaultMessage: 'Upgrade to shift due dates',
|
||||
description: 'Button in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.header': {
|
||||
id: 'datesBanner.resetDatesBanner.header',
|
||||
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
|
||||
description: 'Strong text in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.body': {
|
||||
id: 'datesBanner.resetDatesBanner.body',
|
||||
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
|
||||
the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.`,
|
||||
description: 'Body in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.button': {
|
||||
id: 'datesBanner.resetDatesBanner.button',
|
||||
defaultMessage: 'Shift due dates',
|
||||
description: 'Button in Reset Dates Banner',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function Badge({ children, className, ...rest }) {
|
||||
return (
|
||||
<span
|
||||
className={classNames('dates-badge x-small ml-2 position-absolute', className)}
|
||||
data-testid="dates-badge"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Badge.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Badge.defaultProps = {
|
||||
children: null,
|
||||
className: null,
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
.dates-badge {
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
@@ -4,14 +4,17 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import Timeline from './Timeline';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
import Timeline from './timeline/Timeline';
|
||||
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { initDatesMMP2P } from '../../experiments/mm-p2p';
|
||||
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
|
||||
function DatesTab({ intl }) {
|
||||
const {
|
||||
@@ -19,18 +22,19 @@ function DatesTab({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const mmp2p = initDatesMMP2P(courseId);
|
||||
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
@@ -48,16 +52,14 @@ function DatesTab({ intl }) {
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{ !mmp2p.state.isEnabled && (
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
logUpgradeLinkClick={logUpgradeLinkClick}
|
||||
model="dates"
|
||||
tabFetch={fetchDatesTab}
|
||||
/>
|
||||
) }
|
||||
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||
<SuggestedScheduleHeader />
|
||||
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} />
|
||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||
</>
|
||||
)}
|
||||
<Timeline mmp2p={mmp2p} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,19 +23,37 @@ 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');
|
||||
const { id: courseId } = courseMetadata;
|
||||
|
||||
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
|
||||
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
|
||||
@@ -61,16 +79,9 @@ describe('DatesTab', () => {
|
||||
|
||||
describe('when receiving a full set of dates data', () => {
|
||||
beforeEach(() => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
const courseMetadata = Factory.build('courseHomeMetadata');
|
||||
const { id: courseId } = courseMetadata;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/c/${courseId}/dates`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
@@ -133,34 +144,26 @@ describe('DatesTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dates banner container ', () => {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
|
||||
const { id: courseId } = courseMetadata;
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
describe('Suggested schedule messaging', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
history.push(`/course/${courseId}/dates`);
|
||||
setMetadata({ is_self_paced: true, is_enrolled: true });
|
||||
history.push(`/c/${courseId}/dates`);
|
||||
});
|
||||
|
||||
it('renders datesTabInfoBanner', async () => {
|
||||
it('renders SuggestedScheduleHeader', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: false,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("We've built a suggested schedule to help you stay on track.")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('We’ve built a suggested schedule to help you stay on track. But don’t worry—it’s flexible so you can learn at your own pace.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders upgradeToCompleteGradedBanner', async () => {
|
||||
it('renders UpgradeToCompleteAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
@@ -168,15 +171,14 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
|
||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgradeToResetBanner', async () => {
|
||||
it('renders UpgradeToShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
@@ -184,15 +186,15 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
|
||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders resetDatesBanner', async () => {
|
||||
it('renders ShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
@@ -200,7 +202,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
||||
@@ -216,7 +218,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
// confirm "Shift due dates" button has rendered
|
||||
@@ -244,7 +246,7 @@ describe('DatesTab', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in upgradeToCompleteGradedBanner', async () => {
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
@@ -253,7 +255,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
|
||||
@@ -270,7 +272,7 @@ describe('DatesTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in upgradeToResetBanner', async () => {
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
@@ -279,7 +281,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
|
||||
@@ -296,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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Tooltip, OverlayTrigger } from '@edx/paragon';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from './utils';
|
||||
import { isLearnerAssignment } from '../utils';
|
||||
|
||||
function Day({
|
||||
date,
|
||||
@@ -55,18 +55,16 @@ function Day({
|
||||
|
||||
{/* Content */}
|
||||
<div className="d-inline-block ml-3 pl-2">
|
||||
<div className="mb-1" data-testid="dates-header">
|
||||
<p className="d-inline text-dark-400">
|
||||
<FormattedDate
|
||||
/** [MM-P2P] Experiment */
|
||||
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
|
||||
<FormattedDate
|
||||
/** [MM-P2P] Experiment */
|
||||
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
{badges}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
@@ -82,7 +80,7 @@ function Day({
|
||||
const textColor = available ? 'text-primary-700' : 'text-gray-500';
|
||||
|
||||
return (
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small')} data-testid="dates-item">
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
|
||||
<div>
|
||||
<span className="small">
|
||||
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
|
||||
@@ -3,10 +3,10 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import Day from './Day';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
/** [MM-P2P] Experiment (argument) */
|
||||
export default function Timeline({ mmp2p }) {
|
||||
@@ -64,7 +64,7 @@ export default function Timeline({ mmp2p }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled m-0">
|
||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
|
||||
))}
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Badge } from '@edx/paragon';
|
||||
|
||||
import Badge from './Badge';
|
||||
import messages from './messages';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
import messages from '../messages';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
function hasAccess(item) {
|
||||
return item.learnerHasAccess;
|
||||
@@ -38,14 +38,14 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'bg-warning-300',
|
||||
className: 'text-gray-900',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-dark-100',
|
||||
className: 'text-gray-900',
|
||||
bg: 'bg-light-500',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
@@ -72,12 +72,11 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
shownForDay: items.length && items.every(x => !hasAccess(x)),
|
||||
shownForItem: x => !hasAccess(x),
|
||||
icon: faLock,
|
||||
bg: 'bg-dark-500',
|
||||
bg: 'bg-dark-700',
|
||||
className: 'text-white',
|
||||
},
|
||||
];
|
||||
let color = null; // first color of any badge
|
||||
const marginTopStyle = item ? { marginTop: 0 } : { marginTop: '2px' };
|
||||
const badges = (
|
||||
<>
|
||||
{badgesInfo.map(b => {
|
||||
@@ -97,7 +96,7 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
color = b.bg;
|
||||
}
|
||||
return (
|
||||
<Badge key={b.message.id} style={marginTopStyle} className={classNames(b.bg, b.className)}>
|
||||
<Badge key={b.message.id} className={classNames('ml-2', b.bg, b.className)} data-testid="dates-badge">
|
||||
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
|
||||
{intl.formatMessage(b.message)}
|
||||
</Badge>
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -9,20 +9,21 @@ import { AlertList } from '../../generic/user-messages';
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
import CourseGoalCard from './widgets/CourseGoalCard';
|
||||
import CourseHandouts from './widgets/CourseHandouts';
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
import { fetchOutlineTab } from '../data';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
|
||||
import UpgradeCard from './widgets/UpgradeCard';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
import useCourseStartAlert from './alerts/course-start-alert';
|
||||
import usePrivateCourseAlert from './alerts/private-course-alert';
|
||||
import useScheduledContentAlert from './alerts/scheduled-content-alert';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
||||
@@ -37,6 +38,7 @@ function OutlineTab({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
@@ -44,7 +46,6 @@ function OutlineTab({ intl }) {
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
@@ -58,7 +59,6 @@ function OutlineTab({ intl }) {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
hasEnded,
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
@@ -86,17 +86,18 @@ function OutlineTab({ intl }) {
|
||||
};
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, 'outline-course-alerts', 'course_home');
|
||||
const accessExpirationAlertMasquerade = useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, 'outline-course-alerts');
|
||||
const courseStartAlert = useCourseStartAlert(courseId);
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||
const privateCourseAlert = usePrivateCourseAlert(courseId);
|
||||
const scheduledContentAlert = useScheduledContentAlert(courseId);
|
||||
|
||||
const rootCourseId = courses && Object.keys(courses)[0];
|
||||
|
||||
const courseSock = useRef(null);
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
const logUpgradeToShiftDatesLinkClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
@@ -124,7 +125,7 @@ function OutlineTab({ intl }) {
|
||||
</div>
|
||||
{resumeCourseUrl && (
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
||||
<Button variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -149,24 +150,19 @@ function OutlineTab({ intl }) {
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...accessExpirationAlertMasquerade,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{courseDateBlocks && (
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
logUpgradeLinkClick={logUpgradeLinkClick}
|
||||
model="outline"
|
||||
tabFetch={fetchOutlineTab}
|
||||
/** [MM-P2P] Experiment */
|
||||
isMMP2PEnabled={MMP2P.state.isEnabled}
|
||||
/>
|
||||
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
|
||||
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
|
||||
</>
|
||||
)}
|
||||
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<CourseGoalCard
|
||||
@@ -223,12 +219,14 @@ function OutlineTab({ intl }) {
|
||||
{ MMP2P.state.isEnabled
|
||||
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||
: (
|
||||
<UpgradeCard
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
@@ -245,16 +243,6 @@ function OutlineTab({ intl }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canShowUpgradeSock && (
|
||||
<CourseSock
|
||||
courseId={courseId}
|
||||
offer={offer}
|
||||
orgKey={org}
|
||||
pageLocation="Home Page"
|
||||
ref={courseSock}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,13 +156,13 @@ 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}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dates Banner', () => {
|
||||
describe('Suggested schedule alerts', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
setMetadata({ is_enrolled: true, is_self_paced: true });
|
||||
setTabData({
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: true,
|
||||
@@ -185,15 +185,15 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders upgradeToReset', async () => {
|
||||
it('renders UpgradeToShiftDatesAlert', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
expect(screen.getByText('You are auditing this course,')).toBeInTheDocument();
|
||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
|
||||
expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument();
|
||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in banner', async () => {
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -531,60 +531,22 @@ describe('Outline Tab', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This learner does not have access to this course.', { exact: false });
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows expiration', async () => {
|
||||
it('does not have special masquerade text', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
expiration_date: '2020-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: null,
|
||||
upgrade_url: null,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('Audit Access Expires');
|
||||
});
|
||||
|
||||
it('shows upgrade prompt', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade link', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
|
||||
sendTrackEvent.mockClear();
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: 'course_home_audit_access_expires',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -697,6 +659,247 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', 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: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
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();
|
||||
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();
|
||||
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: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
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();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
it('tracks download cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
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: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
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();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
it('tracks unverified cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
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: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
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();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scheduled Content Alert', () => {
|
||||
it('appears correctly', async () => {
|
||||
const now = new Date();
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { hasScheduledContent: true });
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('More content is coming soon!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('Scheduled Content Alert not present without courseBlocks', () => {
|
||||
it('appears correctly', async () => {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
course_blocks: null,
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -726,6 +929,33 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requesting Certificate Alert', () => {
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Request certificate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate (pdf) Complete Alert', () => {
|
||||
it('appears', async () => {
|
||||
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;
|
||||
|
||||
@@ -7,85 +7,192 @@ import {
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import certMessages from './messages';
|
||||
import certStatusMessages from '../../../progress-tab/certificate-status/messages';
|
||||
import { requestCert } from '../../../data/thunks';
|
||||
|
||||
export const CERT_STATUS_TYPE = {
|
||||
EARNED_NOT_AVAILABLE: 'earned_but_not_available',
|
||||
DOWNLOADABLE: 'downloadable',
|
||||
REQUESTING: 'requesting',
|
||||
UNVERIFIED: 'unverified',
|
||||
};
|
||||
|
||||
function CertificateStatusAlert({ intl, payload }) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
certStatusType,
|
||||
certStatus,
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
} = payload;
|
||||
|
||||
let variant = '';
|
||||
if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE || certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
variant = 'success';
|
||||
}
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const AlertWrapper = (props) => props.children(props);
|
||||
|
||||
let header = '';
|
||||
let body = '';
|
||||
let buttonVisible = false;
|
||||
let buttonMessage = '';
|
||||
const sendAlertClickTracking = (id) => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent(id, {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
};
|
||||
|
||||
if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
|
||||
|
||||
header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
|
||||
body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ended on {courseEndDateFormatted}. Final grades and certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
certificateAvailableDate: certificateAvailableDateFormatted,
|
||||
}}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
} else if (certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
if (isWebCert) {
|
||||
buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
const renderCertAwardedStatus = () => {
|
||||
const alertProps = {
|
||||
variant: 'success',
|
||||
icon: faCheckCircle,
|
||||
iconClassName: 'alert-icon text-success-500',
|
||||
};
|
||||
if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
|
||||
alertProps.body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
certificateAvailableDate: certificateAvailableDateFormatted,
|
||||
}}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
if (isWebCert) {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
}
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = certURL;
|
||||
alertProps.buttonAction = () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked');
|
||||
};
|
||||
} else if (certStatus === CERT_STATUS_TYPE.REQUESTING) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.requestableButton);
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = '';
|
||||
alertProps.buttonAction = () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked');
|
||||
dispatch(requestCert(courseId));
|
||||
};
|
||||
}
|
||||
buttonVisible = true;
|
||||
return alertProps;
|
||||
};
|
||||
|
||||
const renderNotIDVerifiedStatus = () => {
|
||||
const alertProps = {
|
||||
variant: 'warning',
|
||||
icon: faExclamationTriangle,
|
||||
iconClassName: 'alert-icon text-warning-500',
|
||||
header: intl.formatMessage(certStatusMessages.unverifiedHomeHeader),
|
||||
buttonMessage: intl.formatMessage(certStatusMessages.unverifiedHomeButton),
|
||||
body: intl.formatMessage(certStatusMessages.unverifiedHomeBody),
|
||||
buttonVisible: true,
|
||||
buttonLink: getConfig().SUPPORT_URL_ID_VERIFICATION,
|
||||
buttonAction: () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked');
|
||||
},
|
||||
};
|
||||
|
||||
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:
|
||||
case CERT_STATUS_TYPE.DOWNLOADABLE:
|
||||
case CERT_STATUS_TYPE.REQUESTING:
|
||||
alertProps = renderCertAwardedStatus();
|
||||
break;
|
||||
case CERT_STATUS_TYPE.UNVERIFIED:
|
||||
alertProps = renderNotIDVerifiedStatus();
|
||||
break;
|
||||
default:
|
||||
if (notPassingCourseEnded) {
|
||||
alertProps = renderNotPassingCourseEnded();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant={variant}>
|
||||
<div className="row justify-content-between align-items-center">
|
||||
<div className={buttonVisible ? '' : 'col-auto'}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} className="alert-icon text-success-500" />
|
||||
<Alert.Heading>{header}</Alert.Heading>
|
||||
{body}
|
||||
</div>
|
||||
{buttonVisible && (
|
||||
<div className="m-auto m-lg-0 pr-lg-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
href={certURL}
|
||||
>
|
||||
{buttonMessage}
|
||||
</Button>
|
||||
<AlertWrapper {...alertProps}>
|
||||
{({
|
||||
variant,
|
||||
buttonVisible,
|
||||
iconClassName,
|
||||
icon,
|
||||
header,
|
||||
body,
|
||||
buttonAction,
|
||||
buttonLink,
|
||||
buttonMessage,
|
||||
}) => (
|
||||
<Alert variant={variant}>
|
||||
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
|
||||
<div className={buttonVisible ? 'col-lg-8' : 'col-auto'}>
|
||||
<FontAwesomeIcon icon={icon} className={iconClassName} />
|
||||
<Alert.Heading>{header}</Alert.Heading>
|
||||
{body}
|
||||
</div>
|
||||
{buttonVisible && (
|
||||
<div className="flex-grow-0 pt-3 pt-lg-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
href={buttonLink}
|
||||
onClick={() => {
|
||||
if (buttonAction) { buttonAction(); }
|
||||
}}
|
||||
>
|
||||
{buttonMessage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
</Alert>
|
||||
|
||||
)}
|
||||
</AlertWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,11 +200,19 @@ CertificateStatusAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
certificateAvailableDate: PropTypes.string,
|
||||
certStatusType: PropTypes.string,
|
||||
certStatus: PropTypes.string,
|
||||
courseEndDate: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
certURL: PropTypes.string,
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,32 +3,48 @@ import React, { useMemo } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import { CERT_STATUS_TYPE } from './CertificateStatusAlert';
|
||||
|
||||
const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert'));
|
||||
|
||||
function verifyCertStatusType(status) {
|
||||
// This method will only return cert statuses when we want to alert on them.
|
||||
// It should be modified when we want to alert on a new status type.
|
||||
if (status === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
return CERT_STATUS_TYPE.DOWNLOADABLE;
|
||||
switch (status) {
|
||||
case CERT_STATUS_TYPE.DOWNLOADABLE:
|
||||
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
|
||||
case CERT_STATUS_TYPE.REQUESTING:
|
||||
case CERT_STATUS_TYPE.UNVERIFIED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
if (status === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
|
||||
return CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
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 {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
certData,
|
||||
hasEnded,
|
||||
userHasPassingGrade,
|
||||
enrollmentMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
@@ -37,12 +53,13 @@ function useCertificateStatusAlert(courseId) {
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
|
||||
const certStatusType = verifyCertStatusType(certStatus);
|
||||
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}`;
|
||||
@@ -50,20 +67,33 @@ function useCertificateStatusAlert(courseId) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
const hasCertStatus = certStatusType !== '';
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
// Only show if there is a known cert status that we want provide status on.
|
||||
const isVisible = isEnrolled && hasCertStatus;
|
||||
// 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,
|
||||
certStatusType,
|
||||
certStatus,
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function ScheduledContentAlert({ payload }) {
|
||||
const {
|
||||
datesTabLink,
|
||||
} = payload;
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
|
||||
<div className="col-lg-7">
|
||||
<Alert.Heading>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.scheduled-content.heading"
|
||||
defaultMessage="More content is coming soon!"
|
||||
/>
|
||||
</Alert.Heading>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.scheduled-content.body"
|
||||
defaultMessage="This course will have more content released at a future date. Look out for email updates or check back on this course for updates."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow-0 pt-3 pt-lg-0">
|
||||
{datesTabLink && (
|
||||
<Button
|
||||
href={datesTabLink}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.scheduled-content.button"
|
||||
defaultMessage="View Course Schedule"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ScheduledContentAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
datesTabLink: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ScheduledContentAlert;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const ScheduledContentAlert = React.lazy(() => import('./ScheduledCotentAlert'));
|
||||
|
||||
const useScheduledContentAlert = (courseId) => {
|
||||
const {
|
||||
courseBlocks: {
|
||||
courses,
|
||||
},
|
||||
datesWidget: {
|
||||
datesTabLink,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const hasScheduledContent = (
|
||||
!!courses
|
||||
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
|
||||
);
|
||||
const { isEnrolled } = useModel('courseHomeMeta', courseId);
|
||||
const payload = {
|
||||
datesTabLink,
|
||||
};
|
||||
useAlert(hasScheduledContent && isEnrolled, {
|
||||
code: 'ScheduledContentAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return { ScheduledContentAlert };
|
||||
};
|
||||
|
||||
export default useScheduledContentAlert;
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
.upgrade-card {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.upgrade-card-header{
|
||||
margin: 1.25rem;
|
||||
}
|
||||
|
||||
.upsell-warning{
|
||||
background-color: $danger-100;
|
||||
}
|
||||
|
||||
.upsell-warning-light{
|
||||
background-color: $warning-100;
|
||||
}
|
||||
|
||||
.upsell-warning, .upsell-warning-light{
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upgrade-card-ul{
|
||||
margin-left: 3rem;
|
||||
padding-top: 0.875rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.upgrade-card-li{
|
||||
left: -2.125rem;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.upgrade-card-text{
|
||||
padding-top: 0.875rem;
|
||||
padding-right: 1.25rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.upgrade-card-button{
|
||||
margin-left: 1.25rem;
|
||||
margin-right: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.discount-info {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
|
||||
.inline-link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.upgrade-card .upgrade-card-message a{
|
||||
color: $primary-500;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,23 @@ import messages from './messages';
|
||||
function ProgressHeader({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
targetUserId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const { administrator, userId } = getAuthenticatedUser();
|
||||
|
||||
const { studioUrl } = useModel('progress', courseId);
|
||||
const { studioUrl, username } = useModel('progress', courseId);
|
||||
|
||||
const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId);
|
||||
|
||||
const pageTitle = viewingOtherStudentsProgressPage
|
||||
? intl.formatMessage(messages.progressHeaderForTargetUser, { username })
|
||||
: intl.formatMessage(messages.progressHeader);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
||||
<h1>{intl.formatMessage(messages.progressHeader)}</h1>
|
||||
<h1>{pageTitle}</h1>
|
||||
{administrator && studioUrl && (
|
||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
|
||||
@@ -18,12 +18,10 @@ function ProgressTab() {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
const isLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
|
||||
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
@@ -43,7 +41,7 @@ function ProgressTab() {
|
||||
<CertificateStatus />
|
||||
</OnMobile>
|
||||
<CourseGrade />
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={isLocked}>
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
|
||||
<GradeSummary />
|
||||
<DetailedGrades />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('Progress Tab', () => {
|
||||
const courseId = 'course-v1:edX+Test+run';
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/*`);
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
@@ -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 () => {
|
||||
@@ -1034,4 +1185,16 @@ describe('Progress Tab', () => {
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewing progress page of other students by changing url', () => {
|
||||
it('Changing the url changes the header', async () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({ username: 'otherstudent' });
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId, 10), store.dispatch);
|
||||
await act(async () => render(<ProgressTab />, { store }));
|
||||
|
||||
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,9 @@ function CertificateStatus({ intl }) {
|
||||
verificationData,
|
||||
verifiedMode,
|
||||
} = useModel('progress', courseId);
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
@@ -43,6 +46,12 @@ function CertificateStatus({ intl }) {
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
@@ -63,6 +72,7 @@ function CertificateStatus({ intl }) {
|
||||
let buttonLocation;
|
||||
let buttonText;
|
||||
let endDate;
|
||||
let certAvailabilityDate;
|
||||
|
||||
let gradeEventName = 'not_passing';
|
||||
if (userHasPassingGrade) {
|
||||
@@ -73,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';
|
||||
@@ -137,12 +151,13 @@ function CertificateStatus({ intl }) {
|
||||
case 'earned_but_not_available':
|
||||
certCase = 'notAvailable';
|
||||
endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||
certAvailabilityDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="Your certificate will be available soon! After this course officially ends on {endDate}, you will receive an
|
||||
email notification with your certificate."
|
||||
values={{ endDate }}
|
||||
defaultMessage="This course ends on {endDate}. Final grades and certificates are
|
||||
scheduled to be available after {certAvailabilityDate}."
|
||||
values={{ endDate, certAvailabilityDate }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -193,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 (
|
||||
|
||||
@@ -61,10 +61,6 @@ const messages = defineMessages({
|
||||
id: 'progress.certificateStatus.notAvailableHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
notAvailableBody: {
|
||||
id: 'progress.certificateStatus.notAvailableBody',
|
||||
defaultMessage: 'Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.',
|
||||
},
|
||||
upgradeHeader: {
|
||||
id: 'progress.certificateStatus.upgradeHeader',
|
||||
defaultMessage: 'Earn a certificate',
|
||||
@@ -77,6 +73,18 @@ const messages = defineMessages({
|
||||
id: 'progress.certificateStatus.upgradeButton',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
unverifiedHomeHeader: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeHeader',
|
||||
defaultMessage: 'Verify your identity to earn a certificate!',
|
||||
},
|
||||
unverifiedHomeButton: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeButton',
|
||||
defaultMessage: 'Verify my ID',
|
||||
},
|
||||
unverifiedHomeBody: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeBody',
|
||||
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7,6 +7,10 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
if (!completePercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [showCompletePopover, setShowCompletePopover] = useState(false);
|
||||
|
||||
const completeSegmentOffset = (3.6 * completePercentage) / 8;
|
||||
@@ -24,15 +28,6 @@ function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
onFocus={() => setShowCompletePopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-segment complete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
|
||||
strokeDashoffset={lockedSegmentOffset + completePercentage}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showCompletePopover}
|
||||
@@ -49,16 +44,33 @@ function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
<rect x="19" y="3" style={{ transform: `rotate(${completeTooltipDegree}deg)` }} />
|
||||
</OverlayTrigger>
|
||||
|
||||
{/* Complete segment */}
|
||||
<circle
|
||||
className="donut-segment complete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
|
||||
strokeDashoffset={lockedSegmentOffset + completePercentage}
|
||||
/>
|
||||
|
||||
{/* Segment dividers */}
|
||||
{lockedPercentage > 0 && lockedPercentage < 100 && (
|
||||
<circle
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
className="donut-segment divider-stroke"
|
||||
strokeDasharray="0.3 99.7"
|
||||
strokeDashoffset={0.15 + lockedSegmentOffset}
|
||||
/>
|
||||
)}
|
||||
{completePercentage < 100 && lockedPercentage < 100 && lockedPercentage + completePercentage === 100 && (
|
||||
{completePercentage < 100 && lockedPercentage > 0 && lockedPercentage < 100
|
||||
&& lockedPercentage + completePercentage === 100 && (
|
||||
<circle
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
className="donut-segment divider-stroke"
|
||||
strokeDasharray="0.3 99.7"
|
||||
strokeDashoffset="25.15"
|
||||
|
||||
@@ -7,6 +7,10 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
function IncompleteDonutSegment({ incompletePercentage, intl }) {
|
||||
if (!incompletePercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
|
||||
|
||||
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
|
||||
|
||||
@@ -9,7 +9,7 @@ import messages from './messages';
|
||||
function LockedDonutSegment({ intl, lockedPercentage }) {
|
||||
const [showLockedPopover, setShowLockedPopover] = useState(false);
|
||||
|
||||
if (!lockedPercentage > 0) {
|
||||
if (!lockedPercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@ function CourseGrade({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradesFeatureIsPartiallyLocked,
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
@@ -26,13 +25,12 @@ function CourseGrade({ intl }) {
|
||||
|
||||
const passingGrade = Number((Math.min(...Object.values(gradeRange)) * 100).toFixed(0));
|
||||
|
||||
const isLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<section className="text-dark-700 my-4 rounded shadow-sm">
|
||||
{isLocked && <CourseGradeHeader />}
|
||||
<div className={applyLockedOverlay}>
|
||||
{(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>
|
||||
|
||||
@@ -15,19 +15,16 @@ function GradeBar({ intl, passingGrade }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
|
||||
const isLocked = lockedCount > 0;
|
||||
const lockedTooltipClassName = isLocked ? 'locked-overlay' : '';
|
||||
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<div className="col-12 col-sm-6 align-self-center">
|
||||
|
||||
@@ -17,6 +17,7 @@ function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
@@ -67,6 +68,7 @@ function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
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';
|
||||
@@ -21,6 +22,8 @@ function DetailedGrades({ intl }) {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradesFeatureIsPartiallyLocked,
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
@@ -39,6 +42,7 @@ function DetailedGrades({ intl }) {
|
||||
className="muted-link inline-link"
|
||||
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
|
||||
onClick={logOutlineLinkClick}
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
>
|
||||
{intl.formatMessage(messages.courseOutline)}
|
||||
</Hyperlink>
|
||||
@@ -47,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,31 +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,
|
||||
} = 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(
|
||||
@@ -39,23 +30,10 @@ function DetailedGradesTable({ intl, sectionScores }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailedGradesData = subsectionScores.map((subsection) => {
|
||||
const title = (
|
||||
<a
|
||||
href={subsection.url}
|
||||
className="text-dark-700 small"
|
||||
onClick={() => {
|
||||
logSubsectionClicked(subsection.blockKey);
|
||||
}}
|
||||
>
|
||||
{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`}>
|
||||
@@ -87,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,13 +1,38 @@
|
||||
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({
|
||||
intl, assignmentType, footnoteMarker, footnoteId, locked,
|
||||
}) {
|
||||
const {
|
||||
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" /> : '';
|
||||
|
||||
function AssignmentTypeCell({ assignmentType, footnoteMarker, footnoteId }) {
|
||||
return (
|
||||
<div className="small">
|
||||
{assignmentType}
|
||||
<span className="d-inline-flex">{lockedIcon}{assignmentType}</span>
|
||||
{footnoteId && footnoteMarker && (
|
||||
<sup>
|
||||
<a id={`${footnoteId}-ref`} className="text-dark-700" href={`#${footnoteId}-footnote`} aria-describedby="grade-summary-footnote-label">
|
||||
<a
|
||||
id={`${footnoteId}-ref`}
|
||||
className="muted-link"
|
||||
href={`#${footnoteId}-footnote`}
|
||||
aria-describedby="grade-summary-footnote-label"
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
aria-labelledby={`assignmentTypeBlockedIcon${assignmentType}`}
|
||||
>
|
||||
{footnoteMarker}
|
||||
</a>
|
||||
</sup>
|
||||
@@ -17,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);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
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 {
|
||||
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>
|
||||
@@ -21,7 +29,9 @@ function DroppableAssignmentFootnote({ footnotes, intl }) {
|
||||
assignmentType: footnote.assignmentType,
|
||||
}}
|
||||
/>
|
||||
<a className="sr-only" href={`#${footnote.id}-ref`}>{intl.formatMessage(messages.backToContent)}</a>
|
||||
<a className="sr-only" href={`#${footnote.id}-ref`} tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}>
|
||||
{intl.formatMessage(messages.backToContent)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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,15 +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 {
|
||||
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>
|
||||
@@ -33,14 +43,22 @@ function GradeSummaryHeader({ intl }) {
|
||||
iconAs={Icon}
|
||||
className="mb-3"
|
||||
size="sm"
|
||||
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}%`,
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,11 @@ const messages = defineMessages({
|
||||
id: 'progress.header',
|
||||
defaultMessage: 'Your progress',
|
||||
},
|
||||
progressHeaderForTargetUser: {
|
||||
id: 'progress.header.targetUser',
|
||||
defaultMessage: 'Course progress for {username}',
|
||||
description: 'Header when displaying the progress for a different user',
|
||||
},
|
||||
studioLink: {
|
||||
id: 'progress.link.studio',
|
||||
defaultMessage: 'View grading in Studio',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { resetDeadlines } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function ShiftDatesAlert({ fetch, intl, model }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
} = datesBannerInfo;
|
||||
|
||||
if (!missedDeadlines || missedGatedContent || hasEnded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<Row className="w-100 m-0">
|
||||
<Col xs={12} md={9} className="small p-0 pr-md-2">
|
||||
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong>
|
||||
{' '}{intl.formatMessage(messages.shiftDatesBody)}
|
||||
</Col>
|
||||
<Col xs={12} md={3} className="align-self-center text-right mt-3 mt-md-0 p-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => dispatch(resetDeadlines(courseId, model, fetch))}
|
||||
>
|
||||
{intl.formatMessage(messages.shiftDatesButton)}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ShiftDatesAlert.propTypes = {
|
||||
fetch: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
model: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ShiftDatesAlert);
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function SuggestedScheduleHeader({ intl }) {
|
||||
return (
|
||||
<p className="large">
|
||||
{intl.formatMessage(messages.suggestedSchedule)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
SuggestedScheduleHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SuggestedScheduleHeader);
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Row,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function UpgradeToCompleteAlert({ intl, logUpgradeLinkClick }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
if (!contentTypeGatingEnabled || missedDeadlines || hasEnded || !verifiedUpgradeLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-light-200">
|
||||
<Row className="w-100 m-0">
|
||||
<Col xs={12} md={9} className="small p-0 pr-md-2">
|
||||
<Alert.Heading>{intl.formatMessage(messages.upgradeToCompleteHeader)}</Alert.Heading>
|
||||
{intl.formatMessage(messages.upgradeToCompleteBody)}
|
||||
</Col>
|
||||
<Col xs={12} md={3} className="align-self-center text-right mt-3 mt-md-0 p-0">
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeToCompleteButton)}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeToCompleteAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
logUpgradeLinkClick: PropTypes.func,
|
||||
};
|
||||
|
||||
UpgradeToCompleteAlert.defaultProps = {
|
||||
logUpgradeLinkClick: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeToCompleteAlert);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function UpgradeToShiftDatesAlert({ intl, logUpgradeLinkClick, model }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
if (!(contentTypeGatingEnabled && missedDeadlines && missedGatedContent && verifiedUpgradeLink) || hasEnded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-light-200">
|
||||
<Row className="w-100 m-0">
|
||||
<Col xs={12} md={9} className="small p-0 pr-md-2">
|
||||
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong>
|
||||
{' '}{intl.formatMessage(messages.upgradeToShiftBody)}
|
||||
</Col>
|
||||
<Col xs={12} md={3} className="align-self-center text-right mt-3 mt-md-0 p-0">
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeToShiftButton)}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeToShiftDatesAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
logUpgradeLinkClick: PropTypes.func,
|
||||
model: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UpgradeToShiftDatesAlert.defaultProps = {
|
||||
logUpgradeLinkClick: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeToShiftDatesAlert);
|
||||
11
src/course-home/suggested-schedule-messaging/index.js
Normal file
11
src/course-home/suggested-schedule-messaging/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import ShiftDatesAlert from './ShiftDatesAlert';
|
||||
import SuggestedScheduleHeader from './SuggestedScheduleHeader';
|
||||
import UpgradeToCompleteAlert from './UpgradeToCompleteAlert';
|
||||
import UpgradeToShiftDatesAlert from './UpgradeToShiftDatesAlert';
|
||||
|
||||
export {
|
||||
ShiftDatesAlert,
|
||||
SuggestedScheduleHeader,
|
||||
UpgradeToCompleteAlert,
|
||||
UpgradeToShiftDatesAlert,
|
||||
};
|
||||
51
src/course-home/suggested-schedule-messaging/messages.js
Normal file
51
src/course-home/suggested-schedule-messaging/messages.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
suggestedSchedule: {
|
||||
id: 'datesBanner.suggestedSchedule',
|
||||
defaultMessage: 'We’ve built a suggested schedule to help you stay on track. But don’t worry—it’s flexible so you'
|
||||
+ ' can learn at your own pace.',
|
||||
},
|
||||
upgradeToCompleteHeader: {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
|
||||
defaultMessage: 'Upgrade to unlock',
|
||||
description: 'Messaging that prompts users to upgrade their course status in order to access locked course content',
|
||||
},
|
||||
upgradeToCompleteBody: {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
|
||||
defaultMessage: 'You are auditing this course, which means that you are unable to participate in graded'
|
||||
+ ' assignments. To complete graded assignments as part of this course, you can upgrade today.',
|
||||
},
|
||||
upgradeToCompleteButton: {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'Button that prompts users to upgrade their course status',
|
||||
},
|
||||
upgradeToShiftBody: {
|
||||
id: 'datesBanner.upgradeToResetBanner.body',
|
||||
defaultMessage: 'To keep yourself on track, you can update this schedule and shift the past due assignments into'
|
||||
+ ' the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.',
|
||||
},
|
||||
upgradeToShiftButton: {
|
||||
id: 'datesBanner.upgradeToResetBanner.button',
|
||||
defaultMessage: 'Upgrade to shift due dates',
|
||||
description: 'Button that prompts users to upgrade their course status before they can shift their due dates into'
|
||||
+ ' the future',
|
||||
},
|
||||
missedDeadlines: {
|
||||
id: 'datesBanner.resetDatesBanner.header',
|
||||
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule.',
|
||||
},
|
||||
shiftDatesBody: {
|
||||
id: 'datesBanner.resetDatesBanner.body',
|
||||
defaultMessage: 'To keep yourself on track, you can update this schedule and shift the past due assignments into'
|
||||
+ ' the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.',
|
||||
},
|
||||
shiftDatesButton: {
|
||||
id: 'datesBanner.resetDatesBanner.button',
|
||||
defaultMessage: 'Shift due dates',
|
||||
description: 'Button that prompts users to move their due dates into the future',
|
||||
},
|
||||
});
|
||||
|
||||
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,13 +59,15 @@ 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
const checkSpecialExamRedirect = memoize((sequenceStatus, sequence) => {
|
||||
const checkSpecialExamRedirect = memoize((sequenceStatus, sequence, specialExamsEnabled, proctoredExamsEnabled) => {
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequence.isTimeLimited && sequence.legacyWebUrl !== undefined) {
|
||||
const shouldRedirectTimeLimited = sequence.isTimeLimited && !specialExamsEnabled;
|
||||
const shouldRedirectProctored = sequence.isProctored && !proctoredExamsEnabled;
|
||||
if ((shouldRedirectTimeLimited || shouldRedirectProctored) && sequence.legacyWebUrl !== undefined) {
|
||||
global.location.assign(sequence.legacyWebUrl);
|
||||
}
|
||||
}
|
||||
@@ -72,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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -106,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() {
|
||||
@@ -127,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.
|
||||
@@ -178,11 +194,7 @@ class CoursewareContainer extends Component {
|
||||
// Check special exam redirect:
|
||||
// /course/:courseId/:sequenceId(/:unitId) -> :legacyWebUrl
|
||||
// because special exams are currently still served in the legacy LMS frontend.
|
||||
const shouldRedirectProctoredExams = specialExamsEnabledWaffleFlag && sequence.isProctored
|
||||
&& !proctoredExamsEnabledWaffleFlag;
|
||||
if (!specialExamsEnabledWaffleFlag || shouldRedirectProctoredExams) {
|
||||
checkSpecialExamRedirect(sequenceStatus, sequence);
|
||||
}
|
||||
checkSpecialExamRedirect(sequenceStatus, sequence, specialExamsEnabledWaffleFlag, proctoredExamsEnabledWaffleFlag);
|
||||
|
||||
// Check to sequence to sequence-unit redirect:
|
||||
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
|
||||
@@ -205,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 = () => {
|
||||
@@ -215,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;
|
||||
@@ -235,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,
|
||||
@@ -295,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}
|
||||
@@ -329,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,
|
||||
@@ -340,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 = {
|
||||
@@ -363,6 +362,7 @@ CoursewareContainer.propTypes = {
|
||||
previousSequence: sequenceShape,
|
||||
unitViaSequenceId: unitShape,
|
||||
sectionViaSequenceId: sectionShape,
|
||||
unitIdHashKeyMap: unitShape,
|
||||
course: courseShape,
|
||||
sequence: sequenceShape,
|
||||
saveSequencePosition: PropTypes.func.isRequired,
|
||||
@@ -371,6 +371,7 @@ CoursewareContainer.propTypes = {
|
||||
fetchSequence: PropTypes.func.isRequired,
|
||||
specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
|
||||
proctoredExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
|
||||
shortLinkFeatureFlag: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
CoursewareContainer.defaultProps = {
|
||||
@@ -383,6 +384,7 @@ CoursewareContainer.defaultProps = {
|
||||
sectionViaSequenceId: null,
|
||||
course: null,
|
||||
sequence: null,
|
||||
unitIdHashKeyMap: null,
|
||||
};
|
||||
|
||||
const currentCourseSelector = createSelector(
|
||||
@@ -394,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(
|
||||
@@ -414,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;
|
||||
},
|
||||
@@ -428,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;
|
||||
},
|
||||
@@ -465,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) => {
|
||||
@@ -476,6 +515,7 @@ const mapStateToProps = (state) => {
|
||||
sequenceStatus,
|
||||
specialExamsEnabledWaffleFlag,
|
||||
proctoredExamsEnabledWaffleFlag,
|
||||
shortLinkFeatureFlag,
|
||||
} = state.courseware;
|
||||
|
||||
return {
|
||||
@@ -485,6 +525,7 @@ const mapStateToProps = (state) => {
|
||||
sequenceStatus,
|
||||
specialExamsEnabledWaffleFlag,
|
||||
proctoredExamsEnabledWaffleFlag,
|
||||
shortLinkFeatureFlag,
|
||||
course: currentCourseSelector(state),
|
||||
sequence: currentSequenceSelector(state),
|
||||
previousSequence: previousSequenceSelector(state),
|
||||
@@ -492,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}
|
||||
/>
|
||||
@@ -121,12 +121,17 @@ describe('CoursewareContainer', () => {
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
const courseMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`);
|
||||
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: {} });
|
||||
});
|
||||
@@ -141,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');
|
||||
@@ -187,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);
|
||||
@@ -199,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 () => {
|
||||
@@ -214,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);
|
||||
@@ -234,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);
|
||||
}
|
||||
@@ -254,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -311,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,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);
|
||||
@@ -336,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 () => {
|
||||
@@ -347,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);
|
||||
@@ -355,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,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);
|
||||
@@ -372,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 () => {
|
||||
@@ -380,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');
|
||||
@@ -388,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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -416,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);
|
||||
@@ -424,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 }) => {
|
||||
|
||||
@@ -2,21 +2,17 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import useOfferAlert from '../../alerts/offer-alert';
|
||||
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import SidebarNotificationButton from './SidebarNotificationButton';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
|
||||
|
||||
@@ -42,36 +38,26 @@ function Course({
|
||||
].filter(element => element != null).map(element => element.title);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
celebrations,
|
||||
offer,
|
||||
org,
|
||||
userTimezone,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const offerAlert = useOfferAlert(courseId, offer, org, userTimezone, 'course', 'in_course');
|
||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, 'course', 'in_course');
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const celebrationOpen = shouldCelebrateOnSectionLoad(
|
||||
courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations,
|
||||
);
|
||||
|
||||
// REV-2130 TODO: temporary cookie code that should be removed.
|
||||
// In order to see the Value Prop sidebar in prod, a cookie should be set in
|
||||
// the browser console and refresh: document.cookie = 'value_prop_cookie=true';
|
||||
const isValuePropCookieSet = Cookies.get('value_prop_cookie') === 'true';
|
||||
const shouldDisplayNotificationTrigger = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const shouldDisplaySidebarButton = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
|
||||
const shouldDisplayNotificationTrayOpen = useWindowSize().width > responsiveBreakpoints.medium.minWidth;
|
||||
|
||||
const [sidebarVisible, setSidebar] = useState(false);
|
||||
const isSidebarVisible = () => sidebarVisible && setSidebar;
|
||||
const toggleSidebar = () => {
|
||||
if (sidebarVisible) { setSidebar(false); } else { setSidebar(true); }
|
||||
const [notificationTrayVisible, setNotificationTray] = verifiedMode
|
||||
&& shouldDisplayNotificationTrayOpen ? useState(true) : useState(false);
|
||||
const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray;
|
||||
const toggleNotificationTray = () => {
|
||||
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
|
||||
};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -82,17 +68,6 @@ function Course({
|
||||
<Helmet>
|
||||
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
{ /** This conditional is for the [MM-P2P] Experiment */}
|
||||
{ !MMP2P.state.isEnabled && (
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...offerAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="position-relative">
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
@@ -102,10 +77,10 @@ function Course({
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
{ isValuePropCookieSet && shouldDisplaySidebarButton ? (
|
||||
<SidebarNotificationButton
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
{ shouldDisplayNotificationTrigger ? (
|
||||
<NotificationTrigger
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -118,10 +93,9 @@ function Course({
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
sidebarVisible={sidebarVisible}
|
||||
isValuePropCookieSet={isValuePropCookieSet}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
@@ -131,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} /> }
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import Cookies from 'js-cookie';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
|
||||
} from '../../setupTest';
|
||||
import Course from './Course';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import useWindowSize from '../../generic/tabs/useWindowSize';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('../../generic/tabs/useWindowSize');
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
@@ -20,7 +21,6 @@ describe('Course', () => {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
unitNavigationHandler: () => {},
|
||||
toggleSidebar: () => {},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -79,123 +79,16 @@ 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);
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
render(<Course {...mockData} />);
|
||||
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
|
||||
});
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
|
||||
it('displays sidebar notification button', async () => {
|
||||
const toggleSidebar = jest.fn();
|
||||
const isSidebarVisible = jest.fn();
|
||||
|
||||
// REV-2130 TODO: remove cookie related code once temporary value prop cookie code is removed.
|
||||
const cookieName = 'value_prop_cookie';
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn().mockImplementation(() => cookieName);
|
||||
const getSpy = jest.spyOn(Cookies, 'get').mockReturnValueOnce('true');
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleSidebar,
|
||||
isSidebarVisible,
|
||||
};
|
||||
render(<Course {...testData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
|
||||
const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i });
|
||||
|
||||
expect(getSpy).toBeCalledWith(cookieName);
|
||||
expect(sidebarOpenButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays offer and expiration alert', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: null,
|
||||
upgrade_url: null,
|
||||
},
|
||||
offer: {
|
||||
code: 'EDXWELCOME',
|
||||
expiration_date: '2070-01-01T12:00:00Z',
|
||||
original_price: '$100',
|
||||
discounted_price: '$85',
|
||||
percentage: 15,
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
|
||||
await screen.findByText('EDXWELCOME');
|
||||
await screen.findByText('Audit Access Expires');
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of access expiration upgrade link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
user_timezone: 'UTC',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
await screen.findByText('Audit Access Expires');
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseMetadata.id,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: 'in_course_audit_access_expires',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of offer alert link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
offer: {
|
||||
code: 'EDXWELCOME',
|
||||
expiration_date: '2070-01-01T12:00:00Z',
|
||||
original_price: '$100',
|
||||
discounted_price: '$85',
|
||||
percentage: 15,
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
user_timezone: 'UTC',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
await screen.findByText('EDXWELCOME');
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseMetadata.id,
|
||||
linkCategory: 'welcome',
|
||||
linkName: 'in_course_welcome',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger).toHaveClass('trigger-active');
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger).not.toHaveClass('trigger-active');
|
||||
});
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
|
||||
@@ -5,13 +5,12 @@ 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 }) {
|
||||
return (
|
||||
<div className="icon-container">
|
||||
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openSidebarButton)} />
|
||||
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openNotificationTrigger)} />
|
||||
{status === 'active'
|
||||
? <span className={classNames(notificationColor, 'notification-dot')} data-testid="notification-dot" />
|
||||
: null}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user