Compare commits
264 Commits
bjh/accura
...
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 | ||
|
|
1b84930a84 | ||
|
|
99185a9b8a | ||
|
|
e9c3a6bc5e | ||
|
|
5a30cddd32 | ||
|
|
432cb669f5 | ||
|
|
26e1eb64c5 | ||
|
|
30e0e3b8f4 | ||
|
|
46e459aaf3 | ||
|
|
6949fa8201 | ||
|
|
fab2da4586 | ||
|
|
6a402c50ea | ||
|
|
bfeb8c70c0 | ||
|
|
cf61c7a747 | ||
|
|
a003059c8f | ||
|
|
854010ba52 | ||
|
|
07b82b1d87 | ||
|
|
5c204ad0f9 | ||
|
|
5bfca28450 | ||
|
|
a36da4cd84 | ||
|
|
f08a23ecf9 | ||
|
|
3432b0c73b | ||
|
|
c1c3d5c68f | ||
|
|
2a52534442 | ||
|
|
519cf27c4e | ||
|
|
9d07f26f13 | ||
|
|
fdfb60bee8 | ||
|
|
75c9e93241 | ||
|
|
a5ba5655b6 | ||
|
|
46056a0c53 | ||
|
|
05b2439ff6 | ||
|
|
663bf7562b | ||
|
|
58e29d81be | ||
|
|
027eeb8a49 | ||
|
|
e6a4bcd833 | ||
|
|
1da461e2de | ||
|
|
3c07cab8c2 | ||
|
|
110088688a | ||
|
|
d8243d6ea8 | ||
|
|
b1fdbcccf3 | ||
|
|
00205d4b1f | ||
|
|
6100f3ac2e | ||
|
|
6fa6de4543 | ||
|
|
d0bcb19754 | ||
|
|
0f69ed5502 | ||
|
|
039d761a27 | ||
|
|
e2dd081d44 | ||
|
|
7e75671618 | ||
|
|
5ca10042c8 | ||
|
|
64979ecaf0 | ||
|
|
0175c4cf27 | ||
|
|
531f6d96ae | ||
|
|
997be712f1 | ||
|
|
7e5dacf68d | ||
|
|
73fa56d401 | ||
|
|
e46977f50d | ||
|
|
28e1f6f65a | ||
|
|
6c257271bb | ||
|
|
36f567c834 | ||
|
|
c6627a0854 | ||
|
|
608db6d423 | ||
|
|
72168b56f8 | ||
|
|
5af20067b8 | ||
|
|
c8ae544c8b | ||
|
|
28fddc5550 | ||
|
|
ef635b2a9b | ||
|
|
ce69d57dc8 | ||
|
|
43aa6291c3 | ||
|
|
e9f63674ca | ||
|
|
41b97ba638 | ||
|
|
8a7c61b64a | ||
|
|
34dbcb7ea6 | ||
|
|
ca8122686b | ||
|
|
4472541008 | ||
|
|
e5c8dad319 | ||
|
|
3e82152ae7 | ||
|
|
4d2bd81bf0 | ||
|
|
aca45fb26e | ||
|
|
d13bb04648 | ||
|
|
8dc7593780 | ||
|
|
88005ea5d2 | ||
|
|
e86f4a88cc | ||
|
|
cf58ff3d3f | ||
|
|
32ac3632d0 | ||
|
|
6abf8531bb | ||
|
|
353964e75c | ||
|
|
6a376b20c7 | ||
|
|
162f0ceeb5 | ||
|
|
9dcb91af9e | ||
|
|
4d1ed0f357 | ||
|
|
d94c7ad003 | ||
|
|
edef36becb | ||
|
|
29a13f729a | ||
|
|
6a6bddc5c8 | ||
|
|
b12f184d18 |
68
.env
68
.env
@@ -1,32 +1,38 @@
|
||||
# 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=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
DISCOVERY_API_BASE_URL=null
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
INSIGHTS_BASE_URL=null
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
LOGOUT_URL=null
|
||||
LOGO_URL=null
|
||||
LOGO_TRADEMARK_URL=null
|
||||
LOGO_WHITE_URL=null
|
||||
FAVICON_URL=null
|
||||
MARKETING_SITE_BASE_URL=null
|
||||
ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEARCH_CATALOG_URL=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=null
|
||||
STUDIO_BASE_URL=null
|
||||
SUPPORT_URL=null
|
||||
SUPPORT_URL_CALCULATOR_MATH=null
|
||||
SUPPORT_URL_ID_VERIFICATION=null
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE=null
|
||||
TWITTER_HASHTAG=null
|
||||
TWITTER_URL=null
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
INSIGHTS_BASE_URL=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
||||
LMS_BASE_URL=''
|
||||
LOGIN_URL=''
|
||||
LOGOUT_URL=''
|
||||
LOGO_URL=''
|
||||
LOGO_TRADEMARK_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
FAVICON_URL=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
|
||||
STUDIO_BASE_URL=''
|
||||
SUPPORT_URL=''
|
||||
SUPPORT_URL_CALCULATOR_MATH=''
|
||||
SUPPORT_URL_ID_VERIFICATION=''
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
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'
|
||||
@@ -6,6 +9,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
@@ -19,7 +23,7 @@ ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=null
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='edX'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
@@ -27,6 +31,8 @@ SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
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'
|
||||
|
||||
@@ -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'
|
||||
@@ -6,6 +9,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
@@ -19,7 +23,7 @@ ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=null
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='edX'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
@@ -27,6 +31,7 @@ SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
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'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
coverage/*
|
||||
dist/
|
||||
packages/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
jest.config.js
|
||||
|
||||
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
|
||||
|
||||
@@ -37,6 +37,9 @@ Today, if the URL only specifies the course ID, we need to pick a sequence to sh
|
||||
|
||||
Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame.
|
||||
|
||||
_This URL scheme has been expanded upon in
|
||||
[ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
|
||||
|
||||
## "Container" components vs. display components
|
||||
|
||||
This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer.
|
||||
|
||||
93
docs/decisions/0008-liberal-courseware-path-handling.md
Normal file
93
docs/decisions/0008-liberal-courseware-path-handling.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Liberal courseware path handling
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
_This updates some of the content in [ADR #2: Courseware page decisions](./0002-courseware-page-decisions.md)._
|
||||
|
||||
## Context
|
||||
|
||||
The courseware container currently accepts three path forms:
|
||||
|
||||
1. `/course/:courseId`
|
||||
2. `/course/:courseId/:sequenceId`
|
||||
3. `/course/:courseId/:sequenceId/:unitId`
|
||||
|
||||
Forms #1 and #2 are always redirected to Form #3 via simple set of rules:
|
||||
|
||||
* If the sequenceId is not specified, choose the first sequence in the course.
|
||||
* If the unitId is not specified, choose the active unit in the sequence,
|
||||
or the first unit if none are active.
|
||||
|
||||
Thus, Form #3 is effectively the canonoical path;
|
||||
all Learning MFE units should be served from it.
|
||||
We acknowledge that the best user experience is to link directly to the canonoical
|
||||
path when possible, since it skips the redirection steps.
|
||||
Still, there are times when it is necessary or prudent to link just to a course or
|
||||
a sequence.
|
||||
|
||||
Through recent work in the LMS, we are realizing that there are _also_ times where it
|
||||
would be simpler or more performant to link a user to an
|
||||
_entire section without specifying a squence_ or to a
|
||||
_unit without including the sequence_.
|
||||
Specifically, this capability would let as avoid further modulestore or
|
||||
block transformer queries in order to discern the course structure when trying to
|
||||
direct a learner to a section or unit.
|
||||
Futhermore, we hypothesize that being able to build a Learning MFE courseware link
|
||||
with just a unit ID or a section ID will be a nice simplifying quality for future
|
||||
development or debugging.
|
||||
|
||||
## Decision
|
||||
|
||||
The courseware container will accept five total path forms:
|
||||
|
||||
1. `/course/:courseId`
|
||||
2. `/course/:courseId/:sectionId`
|
||||
3. `/course/:courseId/:sectionId/:unitId`
|
||||
4. `/course/:courseId/:sequenceId`
|
||||
5. `/course/:courseId/:unitId`
|
||||
6. `/course/:courseId/:sequenceId/:unitId`
|
||||
|
||||
The redirection rules are as follows:
|
||||
|
||||
* Forms #1 redirects to Form #4 by selecting the first sequence in the course.
|
||||
* Form #2 redirects to Form #4 by selecting to the first sequence in the section.
|
||||
* Form #3 redirects to Form #5 by dropping the section ID.
|
||||
* Form #4 redirects to Form #6 by choosing the active unit in the sequence
|
||||
(or the first unit, if none are active).
|
||||
* Form #5 redirects to Form #6 by filling in the ID of the sequence that the
|
||||
specified unit belongs to (in the edge case where the unit belongs to multiple
|
||||
sequences, the first sequence is selected).
|
||||
|
||||
As before, Form #5 is the canonocial courseware path, which is always redirected to
|
||||
by any of the other courseware path forms.
|
||||
|
||||
## Consequences
|
||||
|
||||
The above decision is implemented.
|
||||
|
||||
## Further work
|
||||
|
||||
At some point, we may decide to further extend the URL scheme to be
|
||||
more human-readable.
|
||||
|
||||
We can't make UsageKeys themselves more readable because they're tied to student state,
|
||||
but we could introduce a new optional `slug` field on Sequences,
|
||||
which would be captured and propagated to the learning_sequences API.
|
||||
We could eventually do something similar to Units, since those slugs only have to be sequence-local.
|
||||
|
||||
So eventually, URLs could look less 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
|
||||
```
|
||||
|
||||
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.
|
||||
17010
package-lock.json
generated
17010
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
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,47 +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-platform": "1.8.4",
|
||||
"@edx/paragon": "13.16.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",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"@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",
|
||||
"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-redux": "7.2.2",
|
||||
"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.7.2",
|
||||
"es-check": "5.1.4",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"@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": "7.0.1",
|
||||
"jest": "27.0.6",
|
||||
"jest-chain": "1.1.5",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.0.1"
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useState } 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, 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 */
|
||||
@@ -21,7 +23,10 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
} = payload;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
@@ -38,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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +54,17 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
);
|
||||
}
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: `${analyticsPageName}_audit_access_expires`,
|
||||
linkType: 'link',
|
||||
pageName: analyticsPageName,
|
||||
});
|
||||
};
|
||||
|
||||
let deadlineMessage = null;
|
||||
if (upgradeDeadline && upgradeUrl) {
|
||||
deadlineMessage = (
|
||||
@@ -92,6 +91,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
@@ -100,7 +100,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
@@ -150,7 +150,10 @@ AccessExpirationAlert.propTypes = {
|
||||
upgradeDeadline: PropTypes.string,
|
||||
upgradeUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
analyticsPageName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,12 +2,16 @@ 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, userTimezone, topic) {
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
const payload = {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
@@ -19,4 +23,20 @@ function useAccessExpirationAlert(accessExpiration, userTimezone, topic) {
|
||||
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 };
|
||||
}
|
||||
|
||||
135
src/alerts/logistration-alert/AccountActivationAlert.jsx
Normal file
135
src/alerts/logistration-alert/AccountActivationAlert.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
AlertModal,
|
||||
Button,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Check, ArrowForward } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { sendActivationEmail } from '../../courseware/data';
|
||||
|
||||
function AccountActivationAlert() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
const handleOnClick = () => {
|
||||
setShowSpinner(true);
|
||||
setShowCheck(false);
|
||||
sendActivationEmail().then(() => {
|
||||
setShowSpinner(false);
|
||||
setShowCheck(true);
|
||||
});
|
||||
};
|
||||
|
||||
const showAccountActivationAlert = Cookies.get('show-account-activation-popup');
|
||||
if (showAccountActivationAlert !== undefined) {
|
||||
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
|
||||
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
|
||||
// of cookie would make it infinit rendering
|
||||
if (Cookies.get('show-account-activation-popup') === undefined) {
|
||||
setShowModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
const title = (
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.title"
|
||||
defaultMessage="Activate your account so you can log back in"
|
||||
description="Title for account activation alert which is shown after the registration"
|
||||
/>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="primary"
|
||||
className=""
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.button"
|
||||
defaultMessage="Continue to {siteName}"
|
||||
description="account activation alert continue button"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
<Icon src={ArrowForward} className="ml-1 d-inline-block align-bottom" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const children = () => {
|
||||
let bodyContent = null;
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.message"
|
||||
defaultMessage="We sent an email to {boldEmail} with a link to activate your account. Can’t find it? Check your spam folder or
|
||||
{sendEmailTag}."
|
||||
description="Message for account activation alert which is shown after the registration"
|
||||
values={{
|
||||
boldEmail: <b>{getAuthenticatedUser() && getAuthenticatedUser().email}</b>,
|
||||
sendEmailTag: (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-is-valid
|
||||
<a href="#" role="button" onClick={handleOnClick}>
|
||||
<FormattedMessage
|
||||
id="account-activation.resend.link"
|
||||
defaultMessage="resend the email"
|
||||
description="Message for resend link in account activation alert which is shown after the registration"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showCheck && showSpinner) {
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
<Spinner
|
||||
animation="border"
|
||||
variant="secondary"
|
||||
style={{ height: '1.5rem', width: '1.5rem' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCheck && !showSpinner) {
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
<Icon
|
||||
src={Check}
|
||||
style={{ height: '1.7rem', width: '1.25rem' }}
|
||||
className="text-success-500 d-inline-block position-fixed"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return bodyContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={showModal}
|
||||
title={title}
|
||||
footerNode={button}
|
||||
onClose={() => ({})}
|
||||
>
|
||||
{children()}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default injectIntl(AccountActivationAlert);
|
||||
@@ -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,86 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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 {
|
||||
offer,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
if (!offer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
code,
|
||||
expirationDate,
|
||||
percentage,
|
||||
upgradeUrl,
|
||||
} = offer;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
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}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
OfferAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
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,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OfferAlert);
|
||||
@@ -1,22 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
|
||||
export function useOfferAlert(offer, userTimezone, topic) {
|
||||
const isVisible = !!offer; // if it exists, show it.
|
||||
const payload = {
|
||||
offer,
|
||||
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>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
import './block.factory';
|
||||
|
||||
// Generates an Array of block IDs, either from a single block or an array of blocks.
|
||||
const getIds = (attr) => {
|
||||
const blocks = Array.isArray(attr) ? attr : [attr];
|
||||
return blocks.map(block => block.id);
|
||||
};
|
||||
|
||||
// Generates an Object in { [block.id]: block } format, either from a single block or an array of blocks.
|
||||
const getBlocks = (attr) => {
|
||||
const blocks = Array.isArray(attr) ? attr : [attr];
|
||||
// eslint-disable-next-line no-return-assign,no-sequences
|
||||
return blocks.reduce((acc, block) => (acc[block.id] = block, acc), {});
|
||||
};
|
||||
|
||||
Factory.define('courseBlocks')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('units', ['courseId'], courseId => ([
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId },
|
||||
),
|
||||
]))
|
||||
.option('sequence', ['courseId', 'units'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.option('course', ['courseId', 'section'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'course', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.attr(
|
||||
'blocks',
|
||||
['course', 'section', 'sequence', 'units'],
|
||||
(course, section, sequence, units) => ({
|
||||
[course.id]: course,
|
||||
...getBlocks(section),
|
||||
...getBlocks(sequence),
|
||||
...getBlocks(units),
|
||||
}),
|
||||
)
|
||||
.attr('root', ['course'], course => course.id);
|
||||
|
||||
/**
|
||||
* Builds a course with a single chapter, sequence, and unit.
|
||||
*/
|
||||
export default function buildSimpleCourseBlocks(courseId, title, options = {}) {
|
||||
const sequenceBlock = options.sequenceBlock || [Factory.build(
|
||||
'block',
|
||||
{ display_name: 'Title of Sequence', type: 'sequential' },
|
||||
{ courseId },
|
||||
)];
|
||||
const sectionBlock = options.sectionBlock || Factory.build(
|
||||
'block',
|
||||
{
|
||||
type: 'chapter',
|
||||
display_name: 'Title of Section',
|
||||
complete: options.complete || false,
|
||||
effort_time: 15,
|
||||
effort_activities: 2,
|
||||
resume_block: options.resumeBlock || false,
|
||||
children: sequenceBlock.map(block => block.id),
|
||||
},
|
||||
{ courseId },
|
||||
);
|
||||
const courseBlock = options.courseBlock || Factory.build(
|
||||
'block',
|
||||
{ type: 'course', display_name: title, children: [sectionBlock.id] },
|
||||
{ courseId },
|
||||
);
|
||||
return {
|
||||
courseBlocks: options.courseBlocks || Factory.build(
|
||||
'courseBlocks',
|
||||
{ courseId },
|
||||
{
|
||||
sequence: sequenceBlock,
|
||||
section: sectionBlock,
|
||||
course: courseBlock,
|
||||
},
|
||||
),
|
||||
sequenceBlock,
|
||||
sectionBlock,
|
||||
courseBlock,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +1,22 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
.sequence(
|
||||
'courseId', (courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
|
||||
)
|
||||
.extend(courseMetadataBase)
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
is_staff: false,
|
||||
original_user_is_staff: false,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
can_load_courseware: false,
|
||||
})
|
||||
.attr(
|
||||
'tabs', ['courseId', 'host'], (courseId, host) => {
|
||||
const tabs = [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{ courseId, path: 'course/' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{ courseId, path: 'discussion/forum/' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{ courseId, path: 'course_wiki' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{ courseId, path: 'progress' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{ courseId, path: 'instructor' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Dates',
|
||||
priority: 5,
|
||||
slug: 'dates',
|
||||
type: 'dates',
|
||||
},
|
||||
{ courseId, path: 'dates' },
|
||||
),
|
||||
];
|
||||
|
||||
return tabs.map(
|
||||
tab => ({
|
||||
tab_id: tab.slug,
|
||||
title: tab.title,
|
||||
url: `${host}${tab.url}`,
|
||||
}),
|
||||
);
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
import './progressTabData.factory';
|
||||
import './upgradeNotificationData.factory';
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import buildSimpleCourseBlocks from './courseBlocks.factory';
|
||||
import { buildMinimalCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
|
||||
|
||||
Factory.define('outlineTabData')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.option('dateBlocks', [])
|
||||
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ([{
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
url: `${host}/courses/${courseId}/bookmarks/`,
|
||||
}]))
|
||||
.option('date_blocks', [])
|
||||
.attr('course_blocks', ['courseId'], courseId => {
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId);
|
||||
const { courseBlocks } = buildMinimalCourseBlocks(courseId);
|
||||
return {
|
||||
blocks: courseBlocks.blocks,
|
||||
};
|
||||
})
|
||||
.attr('dates_widget', ['dateBlocks'], (dateBlocks) => ({
|
||||
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
|
||||
course_date_blocks: dateBlocks,
|
||||
user_timezone: 'UTC',
|
||||
}))
|
||||
@@ -34,12 +29,31 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
cert_data: {
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
selected_goal: null,
|
||||
},
|
||||
course_tools: [
|
||||
{
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
url: 'https://example.com/bookmarks',
|
||||
},
|
||||
{
|
||||
analytics_id: 'edx.tool.verified_upgrade',
|
||||
title: 'Upgrade to Verified',
|
||||
url: 'https://example.com/upgrade',
|
||||
},
|
||||
],
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
@@ -52,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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
// Sample data helpful when developing & testing, to see a variety of configurations.
|
||||
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
|
||||
Factory.define('progressTabData')
|
||||
.attrs({
|
||||
end: '3027-03-31T00:00:00Z',
|
||||
certificate_data: {},
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
incomplete_count: 1,
|
||||
locked_count: 0,
|
||||
},
|
||||
course_grade: {
|
||||
letter_grade: 'pass',
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
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: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 0,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
has_graded_assignment: true,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
enrollment_mode: 'audit',
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
has_scheduled_content: false,
|
||||
studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run',
|
||||
user_has_passing_grade: false,
|
||||
verification_data: {
|
||||
link: null,
|
||||
status: 'none',
|
||||
status_date: null,
|
||||
},
|
||||
verified_mode: null,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('upgradeNotificationData')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.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',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attr('timeOffsetMillis', 0);
|
||||
@@ -5,6 +5,7 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -12,14 +13,24 @@ Object {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"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,
|
||||
@@ -27,6 +38,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -60,6 +72,11 @@ Object {
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
@@ -294,6 +311,7 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -301,14 +319,24 @@ Object {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"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,
|
||||
@@ -316,6 +344,7 @@ Object {
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
@@ -349,17 +378,27 @@ Object {
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"accessExpiration": null,
|
||||
"canShowUpgradeSock": false,
|
||||
"certData": Object {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
"downloadUrl": null,
|
||||
},
|
||||
"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",
|
||||
@@ -371,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 [
|
||||
@@ -386,11 +423,12 @@ 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",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/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",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "Title of Sequence",
|
||||
@@ -405,7 +443,12 @@ Object {
|
||||
Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
Object {
|
||||
"analyticsId": "edx.tool.verified_upgrade",
|
||||
"title": "Upgrade to Verified",
|
||||
"url": "https://example.com/upgrade",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
@@ -421,14 +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",
|
||||
@@ -446,3 +494,191 @@ Object {
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"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,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"completeCount": 1,
|
||||
"incompleteCount": 1,
|
||||
"lockedCount": 0,
|
||||
},
|
||||
"courseGrade": Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
"visiblePercent": 1,
|
||||
},
|
||||
"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 {
|
||||
"averageGrade": 1,
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
"weight": 1,
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": Object {
|
||||
"pass": 0.75,
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"displayName": "First section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"learnerHasAccess": true,
|
||||
"numPointsEarned": 0,
|
||||
"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",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"displayName": "Second section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"displayName": "Second subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
|
||||
"userHasPassingGrade": false,
|
||||
"verificationData": Object {
|
||||
"link": null,
|
||||
"status": "none",
|
||||
"statusDate": null,
|
||||
},
|
||||
"verifiedMode": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -3,6 +3,90 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
|
||||
let dropCount = numDroppable;
|
||||
// Drop the lowest grades
|
||||
while (dropCount && points.length >= dropCount) {
|
||||
const lowestScore = Math.min(...points);
|
||||
const lowestScoreIndex = points.indexOf(lowestScore);
|
||||
points.splice(lowestScoreIndex, 1);
|
||||
dropCount--;
|
||||
}
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
averageGrade = points.reduce((a, b) => a + b, 0) / points.length;
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
};
|
||||
|
||||
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
const gradeByAssignmentType = {};
|
||||
assignmentPolicies.forEach(assignment => {
|
||||
// Create an array with the number of total assignments and set the scores to 0
|
||||
// as placeholders for assignments that have not yet been released
|
||||
gradeByAssignmentType[assignment.type] = {
|
||||
grades: Array(assignment.numTotal).fill(0),
|
||||
numAssignmentsCreated: 0,
|
||||
numTotalExpectedAssignments: assignment.numTotal,
|
||||
};
|
||||
});
|
||||
|
||||
sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
assignmentType,
|
||||
numPointsEarned,
|
||||
numPointsPossible,
|
||||
} = subsection;
|
||||
|
||||
// If a subsection's assignment type does not match an assignment policy in Studio,
|
||||
// we won't be able to include it in this accumulation of grades by assignment type.
|
||||
// This may happen if a course author has removed/renamed an assignment policy in Studio and
|
||||
// neglected to update the subsection's of that assignment type
|
||||
if (!gradeByAssignmentType[assignmentType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
numAssignmentsCreated,
|
||||
} = gradeByAssignmentType[assignmentType];
|
||||
|
||||
numAssignmentsCreated++;
|
||||
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
|
||||
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
|
||||
// of expected assignments
|
||||
gradeByAssignmentType[assignmentType].grades.shift();
|
||||
}
|
||||
// Add the graded assignment to the list
|
||||
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
|
||||
// Record the created assignment
|
||||
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
|
||||
});
|
||||
});
|
||||
|
||||
return assignmentPolicies.map((assignment) => {
|
||||
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
|
||||
gradeByAssignmentType[assignment.type].grades,
|
||||
assignment.weight,
|
||||
assignment.numDroppable,
|
||||
);
|
||||
|
||||
return {
|
||||
averageGrade,
|
||||
numDroppable: assignment.numDroppable,
|
||||
shortLabel: assignment.shortLabel,
|
||||
type: assignment.type,
|
||||
weight: assignment.weight,
|
||||
weightedGrade,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
@@ -27,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,
|
||||
@@ -56,9 +137,14 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
effortTime: block.effort_time,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
lmsWebUrl: block.lms_web_url,
|
||||
showLink: !!block.lms_web_url, // we reconstruct the url ourselves as an MFE-internal <Link>
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
// The presence of an legacy URL for the sequence indicates that we want this
|
||||
// sequence to be a clickable link in the outline (even though, if the new
|
||||
// courseware experience is active, we will ignore `legacyWebUrl` and build a
|
||||
// link to the MFE ourselves).
|
||||
showLink: !!block.legacy_web_url,
|
||||
title: block.display_name,
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -117,31 +203,88 @@ export async function getDatesTabData(courseId) {
|
||||
return {};
|
||||
}
|
||||
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) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
// TODO: (AA-213) update once flag is in place
|
||||
// const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
// try {
|
||||
// const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
// return camelCaseObject(data);
|
||||
// } catch (error) {
|
||||
// const { httpErrorStatus } = error && error.customAttributes;
|
||||
// if (httpErrorStatus === 404) {
|
||||
// global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
// return {};
|
||||
// }
|
||||
// throw error;
|
||||
// }
|
||||
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);
|
||||
|
||||
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
|
||||
camelCasedData.gradingPolicy.assignmentPolicies,
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
|
||||
// assignmentPolicies have been filtered by what's visible to the learner.
|
||||
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
|
||||
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
|
||||
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
|
||||
) : camelCasedData.courseGrade.percent;
|
||||
|
||||
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
|
||||
>= Math.min(...Object.values(data.grading_policy.grade_range));
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// 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 {};
|
||||
}
|
||||
if (httpErrorStatus === 401) {
|
||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProctoringInfoData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
||||
export async function getProctoringInfoData(courseId, username) {
|
||||
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)}`;
|
||||
}
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
@@ -154,11 +297,30 @@ export async function getProctoringInfoData(courseId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||
|
||||
let timeOffsetMillis = 0;
|
||||
if (headerDate !== undefined) {
|
||||
const headerTime = Date.parse(headerDate);
|
||||
const roundTripMillis = requestTime - responseTime;
|
||||
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
|
||||
timeOffsetMillis = headerTime - localTime;
|
||||
}
|
||||
|
||||
return timeOffsetMillis;
|
||||
}
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
let { tabData } = {};
|
||||
let requestTime = Date.now();
|
||||
let responseTime = requestTime;
|
||||
try {
|
||||
requestTime = Date.now();
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
responseTime = Date.now();
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
@@ -170,37 +332,51 @@ export async function getOutlineTabData(courseId) {
|
||||
|
||||
const {
|
||||
data,
|
||||
headers,
|
||||
} = tabData;
|
||||
|
||||
const accessExpiration = camelCaseObject(data.access_expiration);
|
||||
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
||||
const certData = camelCaseObject(data.cert_data);
|
||||
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
||||
const courseGoals = camelCaseObject(data.course_goals);
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
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,
|
||||
canShowUpgradeSock,
|
||||
certData,
|
||||
courseBlocks,
|
||||
courseGoals,
|
||||
courseTools,
|
||||
datesBannerInfo,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
enrollmentMode,
|
||||
handoutsHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
offer,
|
||||
resumeCourse,
|
||||
timeOffsetMillis, // This should move to a global time correction reference
|
||||
userHasPassingGrade,
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
shortLinkFeatureFlag,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
16
src/course-home/data/api.test.js
Normal file
16
src/course-home/data/api.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getTimeOffsetMillis } from './api';
|
||||
|
||||
describe('Calculate the time offset properly', () => {
|
||||
it('Should return 0 if the headerDate is not set', async () => {
|
||||
const offset = getTimeOffsetMillis(undefined, undefined, undefined);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('Should return the offset', async () => {
|
||||
const headerDate = '2021-04-13T11:01:58.135Z';
|
||||
const requestTime = new Date('2021-04-12T11:01:57.135Z');
|
||||
const responseTime = new Date('2021-04-12T11:01:58.635Z');
|
||||
const offset = getTimeOffsetMillis(headerDate, requestTime, responseTime);
|
||||
expect(offset).toBe(86398750);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const { courseId } = courseHomeMetadata;
|
||||
const { id: courseId } = courseHomeMetadata;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
@@ -88,6 +88,49 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchProgressTab', () => {
|
||||
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${progressBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const progressTabData = Factory.build('progressTabData', { courseId });
|
||||
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
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', () => {
|
||||
it('Should save course goal', async () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||
|
||||
@@ -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,91 +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,
|
||||
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: () => global.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'upgradeToResetBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
|
||||
clickHandler: () => 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,
|
||||
model: PropTypes.string.isRequired,
|
||||
tabFetch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
DatesBannerContainer.defaultProps = {
|
||||
hasEnded: false,
|
||||
};
|
||||
|
||||
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,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function Badge({ children, className }) {
|
||||
return (
|
||||
<span
|
||||
className={classNames('dates-badge small ml-2', className)}
|
||||
data-testid="dates-badge"
|
||||
>
|
||||
{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: 2px 8px 3px 8px;
|
||||
}
|
||||
@@ -1,46 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
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 {
|
||||
courseId,
|
||||
} = 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,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'dates_upgrade',
|
||||
linkType: 'button',
|
||||
pageName: 'dates_tab',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div role="heading" aria-level="1" className="h2 my-3">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{ !mmp2p.state.isEnabled && (
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Route } from 'react-router';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { waitForElementToBeRemoved } from '@testing-library/dom';
|
||||
@@ -18,22 +19,41 @@ import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
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
|
||||
@@ -59,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 { 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);
|
||||
});
|
||||
@@ -131,34 +144,26 @@ describe('DatesTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dates banner container ', () => {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
|
||||
const { 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,
|
||||
@@ -166,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,
|
||||
@@ -182,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,
|
||||
@@ -198,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());
|
||||
@@ -214,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
|
||||
@@ -241,5 +245,108 @@ describe('DatesTab', () => {
|
||||
// confirm "Shift due dates" button has not rendered
|
||||
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'dates_upgrade',
|
||||
linkType: 'button',
|
||||
pageName: 'dates_tab',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: true,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'dates_upgrade',
|
||||
linkType: 'button',
|
||||
pageName: 'dates_tab',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,20 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedTime,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
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,
|
||||
@@ -50,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-500 font-weight-bold">
|
||||
<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) => {
|
||||
@@ -70,16 +73,27 @@ function Day({
|
||||
? getBadgeListAndColor(new Date(mmp2p.state.upgradeDeadline), intl, item, items)
|
||||
: getBadgeListAndColor(date, intl, item, items);
|
||||
|
||||
const showDueDateTime = item.dateType === 'assignment-due-date';
|
||||
const showLink = item.link && isLearnerAssignment(item);
|
||||
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
|
||||
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
|
||||
const textColor = available ? 'text-dark-500' : 'text-dark-200';
|
||||
const textColor = available ? 'text-primary-700' : 'text-gray-500';
|
||||
|
||||
return (
|
||||
<div key={item.title + item.date} className={textColor} data-testid="dates-item">
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
|
||||
<div>
|
||||
<span className="font-weight-bold small mt-1">
|
||||
{item.assignmentType && `${item.assignmentType}: `}{title}
|
||||
<span className="small">
|
||||
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
|
||||
{showDueDateTime && (
|
||||
<span>
|
||||
<span className="mx-1">due</span>
|
||||
<FormattedTime
|
||||
value={date}
|
||||
timeZoneName="short"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{itemBadges}
|
||||
{item.extraInfo && (
|
||||
@@ -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,7 +72,7 @@ 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',
|
||||
},
|
||||
];
|
||||
@@ -96,7 +96,7 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
color = b.bg;
|
||||
}
|
||||
return (
|
||||
<Badge key={b.message.id} 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,8 +1,11 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { isLearnerAssignment } from '../dates-tab/utils';
|
||||
import './DateSummary.scss';
|
||||
|
||||
@@ -12,12 +15,30 @@ export default function DateSummary({
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const showMMP2P = mmp2p.state.isEnabled && (dateBlock.dateType === 'verified-upgrade-deadline');
|
||||
|
||||
const logVerifiedUpgradeClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'course_home_dates',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="container p-0 mb-3 small text-dark-500">
|
||||
<div className="row">
|
||||
@@ -50,15 +71,27 @@ export default function DateSummary({
|
||||
) : (
|
||||
<div className="row ml-4 pr-2">
|
||||
<div className="date-summary-text">
|
||||
{linkedTitle
|
||||
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
|
||||
{!linkedTitle
|
||||
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
|
||||
{linkedTitle && (
|
||||
<div className="font-weight-bold mt-2">
|
||||
<a href={dateBlock.link}>{dateBlock.title}</a>
|
||||
</div>
|
||||
)}
|
||||
{!linkedTitle && (
|
||||
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
|
||||
)}
|
||||
</div>
|
||||
{dateBlock.description
|
||||
&& <div className="date-summary-text mt-1">{dateBlock.description}</div>}
|
||||
{!linkedTitle && dateBlock.link
|
||||
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
|
||||
{dateBlock.description && (
|
||||
<div className="date-summary-text mt-1">{dateBlock.description}</div>
|
||||
)}
|
||||
{!linkedTitle && dateBlock.link && (
|
||||
<a
|
||||
href={dateBlock.link}
|
||||
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
|
||||
className="description-link"
|
||||
>
|
||||
{dateBlock.linkText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Toast } from '@edx/paragon';
|
||||
@@ -9,24 +9,25 @@ 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 useCertificateAvailableAlert from './alerts/certificate-available-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 useOfferAlert from '../../alerts/offer-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';
|
||||
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
|
||||
@@ -37,13 +38,14 @@ function OutlineTab({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
@@ -51,18 +53,18 @@ function OutlineTab({ intl }) {
|
||||
courseGoals: {
|
||||
goalOptions,
|
||||
selectedGoal,
|
||||
},
|
||||
} = {},
|
||||
datesBannerInfo,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
hasEnded,
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
@@ -70,26 +72,40 @@ function OutlineTab({ intl }) {
|
||||
const [goalToastHeader, setGoalToastHeader] = useState('');
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const logResumeCourseClick = () => {
|
||||
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
|
||||
courserun_key: courseId,
|
||||
...eventProperties,
|
||||
event_type: hasVisitedCourse ? 'resume' : 'start',
|
||||
org_key: org,
|
||||
url: resumeCourseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
const offerAlert = useOfferAlert(offer, userTimezone, 'outline-course-alerts');
|
||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'outline-course-alerts');
|
||||
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 logUpgradeToShiftDatesLinkClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'course_home_upgrade_shift_dates',
|
||||
linkType: 'button',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
};
|
||||
|
||||
/** [[MM-P2P] Experiment */
|
||||
const MMP2P = initHomeMMP2P(courseId);
|
||||
@@ -103,13 +119,13 @@ function OutlineTab({ intl }) {
|
||||
>
|
||||
{goalToastHeader}
|
||||
</Toast>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-between">
|
||||
<div className="row w-100 mx-0 my-3 justify-content-between">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<div role="heading" aria-level="1" className="h2">{title}</div>
|
||||
</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>
|
||||
@@ -117,6 +133,7 @@ function OutlineTab({ intl }) {
|
||||
</div>
|
||||
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
|
||||
<div className="row course-outline-tab">
|
||||
<AccountActivationAlert />
|
||||
<div className="col-12">
|
||||
<AlertList
|
||||
topic="outline-private-alerts"
|
||||
@@ -133,24 +150,19 @@ function OutlineTab({ intl }) {
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...accessExpirationAlertMasquerade,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...offerAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{courseDateBlocks && (
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
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
|
||||
@@ -189,6 +201,7 @@ function OutlineTab({ intl }) {
|
||||
<div className="col col-12 col-md-4">
|
||||
<ProctoringInfoPanel
|
||||
courseId={courseId}
|
||||
username={username}
|
||||
/>
|
||||
{courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<UpdateGoalSelector
|
||||
@@ -206,11 +219,17 @@ 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}
|
||||
onLearnMore={
|
||||
canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null
|
||||
}
|
||||
org={org}
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
@@ -224,16 +243,6 @@ function OutlineTab({ intl }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canShowUpgradeSock && (
|
||||
<CourseSock
|
||||
courseId={courseId}
|
||||
offer={offer}
|
||||
orgKey={org}
|
||||
pageLocation="Home Page"
|
||||
ref={courseSock}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Cookies from 'js-cookie';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory';
|
||||
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
|
||||
import {
|
||||
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, act,
|
||||
} from '../../setupTest';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
import * as thunks from '../data/thunks';
|
||||
import initializeStore from '../../store';
|
||||
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
|
||||
import OutlineTab from './OutlineTab';
|
||||
|
||||
initializeMockApp();
|
||||
@@ -27,14 +29,14 @@ 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', { courseId });
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('outlineTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { courseId, ...attributes }, options);
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('expands section that contains resume block', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
@@ -112,7 +114,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: true });
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { complete: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
@@ -121,7 +123,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('displays correct icon for incomplete assignment', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: false });
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { complete: false });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
@@ -130,7 +132,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to legacy courseware', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: false,
|
||||
});
|
||||
@@ -144,7 +146,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to courseware MFE', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: true,
|
||||
});
|
||||
@@ -154,7 +156,59 @@ 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('Suggested schedule alerts', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({ is_enrolled: true, is_self_paced: true });
|
||||
setTabData({
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: true,
|
||||
missed_deadlines: true,
|
||||
missed_gated_content: true,
|
||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2010-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Missed assignment',
|
||||
extra_info: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders UpgradeToShiftDatesAlert', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
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 UpgradeToShiftDatesAlert', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: 'Upgrade to shift due dates' });
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'course_home_upgrade_shift_dates',
|
||||
linkType: 'button',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +268,63 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Dates', () => {
|
||||
it('renders when course date blocks are populated', async () => {
|
||||
const startDate = new Date();
|
||||
startDate.setHours(startDate.getHours() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-start-date',
|
||||
date: startDate.toISOString(),
|
||||
title: 'Start',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('heading', { name: 'Important dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when course date blocks are not populated', async () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Important dates' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade link', async () => {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'verified-upgrade-deadline',
|
||||
date: tomorrow.toISOString(),
|
||||
link: 'https://example.com/upgrade',
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified Certificate' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'course_home_dates',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Goals', () => {
|
||||
const goalOptions = [
|
||||
['certify', 'Earn a certificate'],
|
||||
@@ -312,6 +423,51 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Tools', () => {
|
||||
it('renders title when tools are available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Bookmarks' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render title when tools are not available', async () => {
|
||||
setTabData({
|
||||
course_tools: [],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('analytics sent when upgrade link clicked', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
|
||||
sendTrackEvent.mockClear();
|
||||
sendTrackingLogEvent.mockClear();
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'course_home_course_tools',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.tool.accessed', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
course_id: courseId,
|
||||
is_staff: false,
|
||||
tool_name: 'edx.tool.verified_upgrade',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert List', () => {
|
||||
describe('Private Course Alert', () => {
|
||||
it('does not display alert for enrolled user', async () => {
|
||||
@@ -329,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();
|
||||
@@ -339,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();
|
||||
});
|
||||
@@ -375,33 +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 });
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,7 +557,7 @@ describe('Outline Tab', () => {
|
||||
startDate.setDate(startDate.getDate() + 100);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-start-date',
|
||||
date: startDate.toISOString(),
|
||||
@@ -430,7 +575,7 @@ describe('Outline Tab', () => {
|
||||
startDate.setHours(startDate.getHours() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-start-date',
|
||||
date: startDate.toISOString(),
|
||||
@@ -451,7 +596,7 @@ describe('Outline Tab', () => {
|
||||
endDate.setDate(endDate.getDate() + 13);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: endDate.toISOString(),
|
||||
@@ -469,7 +614,7 @@ describe('Outline Tab', () => {
|
||||
endDate.setHours(endDate.getHours() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: endDate.toISOString(),
|
||||
@@ -490,8 +635,15 @@ describe('Outline Tab', () => {
|
||||
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({}, {
|
||||
dateBlocks: [
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: tomorrow.toISOString(),
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
@@ -505,8 +657,329 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('We are working on generating course certificates.');
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate (web) Complete 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.DOWNLOADABLE,
|
||||
cert_web_view_url: 'certificate/testuuid',
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
const yesterday = 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,
|
||||
certificate_available_date: null,
|
||||
download_url: 'download/url',
|
||||
},
|
||||
}, {
|
||||
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.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,7 +1004,7 @@ describe('Outline Tab', () => {
|
||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for rejected', async () => {
|
||||
@@ -546,7 +1019,7 @@ describe('Outline Tab', () => {
|
||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for submitted', async () => {
|
||||
@@ -559,7 +1032,7 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for second_review_required', async () => {
|
||||
@@ -572,7 +1045,7 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for other_course_approved if not expiring soon', async () => {
|
||||
@@ -587,8 +1060,8 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, it is highly recommended that you complete this course’s onboarding exam in order to ensure that your device still meets the requirements for proctoring.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Your onboarding exam has been approved in another course.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays expiration warning', async () => {
|
||||
@@ -603,8 +1076,8 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for no status', async () => {
|
||||
@@ -619,7 +1092,7 @@ describe('Outline Tab', () => {
|
||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not appear for 404', async () => {
|
||||
@@ -659,7 +1132,7 @@ describe('Outline Tab', () => {
|
||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -671,7 +1144,7 @@ describe('Outline Tab', () => {
|
||||
|
||||
it('displays link to upgrade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade ($149)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('viewing upgrade card sends analytics', async () => {
|
||||
@@ -697,17 +1170,17 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('clicking upgrade link sends analytics', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
|
||||
sendTrackEvent.mockClear();
|
||||
sendTrackingLogEvent.mockClear();
|
||||
|
||||
await fetchAndRender();
|
||||
const upgradeButton = screen.getByRole('link', { name: 'Upgrade ($149)' });
|
||||
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
|
||||
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
// 3 sendTrackEvent calls are expected because 1 happens on render, and 2 happen onClick
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'Promotion Clicked', {
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
creative: 'sidebarupsell',
|
||||
@@ -715,7 +1188,7 @@ describe('Outline Tab', () => {
|
||||
position: 'sidebar-message',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.bi.ecommerce.upsell_links_clicked', {
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'green_upgrade',
|
||||
@@ -724,17 +1197,63 @@ describe('Outline Tab', () => {
|
||||
pageName: 'course_home',
|
||||
});
|
||||
|
||||
// 3 sendTrackingLogEvent calls are expected because 1 happens on render, and 2 happen onClick
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(3);
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
});
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(3, 'edx.course.enrollment.upgrade.clicked', {
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.course.enrollment.upgrade.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
location: 'sidebar-message',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accont Activation Alert', () => {
|
||||
beforeEach(() => {
|
||||
const intersectionObserverMock = () => ({
|
||||
observe: () => null,
|
||||
disconnect: () => null,
|
||||
});
|
||||
window.IntersectionObserver = jest.fn().mockImplementation(intersectionObserverMock);
|
||||
});
|
||||
it('displays account activation alert if cookie is set true', async () => {
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn().mockImplementation(() => 'true');
|
||||
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Activate your account so you can log back in')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'resend the email' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('do not displays account activation alert if cookie is not set true', async () => {
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn();
|
||||
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Activate your account so you can log back in')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends account activation email on clicking the resened email in account activation alert', async () => {
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn().mockImplementation(() => 'true');
|
||||
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const resendEmailUrl = `${getConfig().LMS_BASE_URL}/api/send_account_activation_email`;
|
||||
axiosMock.onPost(resendEmailUrl).reply(200, {});
|
||||
|
||||
const resendLink = screen.getByRole('button', { name: 'resend the email' });
|
||||
fireEvent.click(resendLink);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
expect(axiosMock.history.post[0].url).toEqual(resendEmailUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -85,6 +106,7 @@ function Section({
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
icon={faPlus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
@@ -92,19 +114,12 @@ function Section({
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
icon={faMinus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -28,7 +28,7 @@ function SequenceLink({
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
lmsWebUrl,
|
||||
legacyWebUrl,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
@@ -44,7 +44,11 @@ function SequenceLink({
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
|
||||
const coursewareUrl = canLoadCourseware ? <Link to={`/course/${courseId}/${id}`}>{title}</Link> : <Hyperlink destination={lmsWebUrl}>{title}</Hyperlink>;
|
||||
const coursewareUrl = (
|
||||
canLoadCourseware
|
||||
? <Link to={`/c/${courseId}/${id}`}>{title}</Link>
|
||||
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
|
||||
);
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
return (
|
||||
@@ -92,7 +96,6 @@ function SequenceLink({
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, FormattedRelative } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
function CertificateAvailableAlert({ payload }) {
|
||||
const {
|
||||
certDate,
|
||||
username,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.title"
|
||||
defaultMessage="We are working on generating course certificates."
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="If you have earned a certificate, you will be able to access it {timeRemaining}. You will also be able to view your certificates on your {profileLink}."
|
||||
values={{
|
||||
profileLink: (
|
||||
<Hyperlink
|
||||
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.profile"
|
||||
defaultMessage="Learner Profile"
|
||||
/>
|
||||
</Hyperlink>
|
||||
),
|
||||
timeRemaining: (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={certDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateAvailableAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
certDate: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CertificateAvailableAlert;
|
||||
@@ -1,44 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailableAlert'));
|
||||
|
||||
function useCertificateAvailableAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
const username = authenticatedUser ? authenticatedUser.username : '';
|
||||
|
||||
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const hasEnded = endBlock ? endDate < new Date() : false;
|
||||
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
|
||||
const payload = {
|
||||
certDate: certBlock && certBlock.date,
|
||||
username,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCertificateAvailableAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCertificateAvailableAlert: CertificateAvailableAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCertificateAvailableAlert;
|
||||
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
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, 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,
|
||||
certStatus,
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
} = payload;
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const AlertWrapper = (props) => props.children(props);
|
||||
|
||||
const sendAlertClickTracking = (id) => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent(id, {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
}
|
||||
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 (
|
||||
<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>
|
||||
</Alert>
|
||||
|
||||
)}
|
||||
</AlertWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateStatusAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
certificateAvailableDate: 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,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateStatusAlert);
|
||||
@@ -0,0 +1,107 @@
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isWebCert = downloadUrl === null;
|
||||
const isVerifiedEnrollmentMode = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
|
||||
);
|
||||
let certURL = '';
|
||||
if (certWebViewUrl) {
|
||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
} else if (downloadUrl) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
// 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,
|
||||
certStatus,
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
};
|
||||
|
||||
useAlert(isVisible || notPassingCourseEnded, {
|
||||
code: 'clientCertificateStatusAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCertificateStatusAlert: CertificateStatusAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCertificateStatusAlert;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
certStatusEarnedNotAvailableHeader: {
|
||||
id: 'cert.alert.earned.unavailable.header',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
description: 'Header alerting the user that their certificate will be available soon.',
|
||||
},
|
||||
certStatusDownloadableHeader: {
|
||||
id: 'cert.alert.earned.ready.header',
|
||||
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
|
||||
|
||||
@@ -37,7 +37,6 @@ function CourseEndAlert({ payload }) {
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
@@ -79,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}."
|
||||
@@ -42,7 +42,6 @@ function CourseStartAlert({ payload }) {
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
@@ -56,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;
|
||||
@@ -22,7 +22,7 @@ const messages = defineMessages({
|
||||
},
|
||||
dates: {
|
||||
id: 'learning.outline.dates',
|
||||
defaultMessage: 'Upcoming Dates',
|
||||
defaultMessage: 'Important dates',
|
||||
},
|
||||
editGoal: {
|
||||
id: 'learning.outline.editGoal',
|
||||
@@ -166,7 +166,7 @@ const messages = defineMessages({
|
||||
},
|
||||
verifiedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.verified',
|
||||
defaultMessage: 'You can now take proctored exams in this course.',
|
||||
defaultMessage: 'Your onboarding exam has been approved in this course.',
|
||||
},
|
||||
rejectedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.rejected',
|
||||
@@ -178,11 +178,15 @@ const messages = defineMessages({
|
||||
},
|
||||
otherCourseApprovedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.otherCourseApproved',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, it is highly recommended that you complete this course’s onboarding exam in order to ensure that your device still meets the requirements for proctoring.',
|
||||
defaultMessage: 'Your onboarding exam has been approved in another course.',
|
||||
},
|
||||
otherCourseApprovedProctoringDetail: {
|
||||
id: 'learning.proctoringPanel.detail.otherCourseApproved',
|
||||
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
|
||||
},
|
||||
expiringSoonProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
},
|
||||
proctoringPanelGeneralInfo: {
|
||||
id: 'learning.proctoringPanel.generalInfo',
|
||||
@@ -194,12 +198,16 @@ const messages = defineMessages({
|
||||
},
|
||||
proctoringPanelGeneralTime: {
|
||||
id: 'learning.proctoringPanel.generalTime',
|
||||
defaultMessage: 'Onboarding profile review, including identity verification, can take 2+ business days.',
|
||||
defaultMessage: 'Onboarding profile review can take 2+ business days.',
|
||||
},
|
||||
proctoringOnboardingButton: {
|
||||
id: 'learning.proctoringPanel.onboardingButton',
|
||||
defaultMessage: 'Complete Onboarding',
|
||||
},
|
||||
proctoringOnboardingPracticeButton: {
|
||||
id: 'learning.proctoringPanel.onboardingPracticeButton',
|
||||
defaultMessage: 'View Onboarding Exam',
|
||||
},
|
||||
proctoringOnboardingButtonNotOpen: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonNotOpen',
|
||||
defaultMessage: 'Onboarding Opens: {releaseDate}',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -23,15 +23,29 @@ function CourseTools({ courseId, intl }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const logClick = (analyticsId) => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackingLogEvent('edx.course.tool.accessed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
...eventProperties,
|
||||
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
|
||||
is_staff: administrator,
|
||||
tool_name: analyticsId,
|
||||
});
|
||||
|
||||
if (analyticsId === 'edx.tool.verified_upgrade') {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: '(none)',
|
||||
linkName: 'course_home_course_tools',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderIcon = (iconClasses) => {
|
||||
|
||||
@@ -2,14 +2,13 @@ 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';
|
||||
|
||||
import messages from '../messages';
|
||||
import { getProctoringInfoData } from '../../data/api';
|
||||
|
||||
function ProctoringInfoPanel({ courseId, intl }) {
|
||||
function ProctoringInfoPanel({ courseId, username, intl }) {
|
||||
const [status, setStatus] = useState('');
|
||||
const [link, setLink] = useState('');
|
||||
const [releaseDate, setReleaseDate] = useState(null);
|
||||
@@ -58,9 +57,9 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
|
||||
function getBorderClass() {
|
||||
let borderClass = '';
|
||||
if (readableStatus === readableStatuses.submitted) {
|
||||
if ([readableStatuses.submitted, readableStatuses.expiringSoon].includes(readableStatus)) {
|
||||
borderClass = 'proctoring-onboarding-submitted';
|
||||
} else if (readableStatus === readableStatuses.verified) {
|
||||
} else if ([readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus)) {
|
||||
borderClass = 'proctoring-onboarding-success';
|
||||
}
|
||||
return borderClass;
|
||||
@@ -74,7 +73,7 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getProctoringInfoData(courseId)
|
||||
getProctoringInfoData(courseId, username)
|
||||
.then(
|
||||
response => {
|
||||
if (response) {
|
||||
@@ -106,6 +105,9 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
<p>
|
||||
{intl.formatMessage(messages[`${readableStatus}ProctoringMessage`])}
|
||||
</p>
|
||||
<p>
|
||||
{readableStatus === readableStatuses.otherCourseApproved && intl.formatMessage(messages[`${readableStatus}ProctoringDetail`])}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
|
||||
@@ -128,8 +130,17 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
{isNotYetSubmitted(status) && (
|
||||
<>
|
||||
{!isNotYetReleased(releaseDate) && (
|
||||
<Button variant="primary" block href={`${getConfig().LMS_BASE_URL}${link}`}>
|
||||
{intl.formatMessage(messages.proctoringOnboardingButton)}
|
||||
<Button variant="primary" block href={link}>
|
||||
{readableStatus === readableStatuses.otherCourseApproved && (
|
||||
<>
|
||||
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
|
||||
</>
|
||||
)}
|
||||
{readableStatus !== readableStatuses.otherCourseApproved && (
|
||||
<>
|
||||
{intl.formatMessage(messages.proctoringOnboardingButton)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isNotYetReleased(releaseDate) && (
|
||||
@@ -160,7 +171,12 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
|
||||
ProctoringInfoPanel.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
username: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ProctoringInfoPanel.defaultProps = {
|
||||
username: null,
|
||||
};
|
||||
|
||||
export default injectIntl(ProctoringInfoPanel);
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { UpgradeButton } from '../../../generic/upgrade-button';
|
||||
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
|
||||
|
||||
function UpgradeCard({ courseId, intl, onLearnMore }) {
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
offer,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const promotionEventProperties = {
|
||||
creative: 'sidebarupsell',
|
||||
name: 'In-Course Verification Prompt',
|
||||
position: 'sidebar-message',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
...eventProperties,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
|
||||
sendTrackEvent('Promotion Viewed', promotionEventProperties);
|
||||
}, []);
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
|
||||
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
|
||||
...eventProperties,
|
||||
location: 'sidebar-message',
|
||||
});
|
||||
sendTrackEvent('Promotion Clicked', promotionEventProperties);
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: 'course_home_green',
|
||||
linkType: 'button',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
|
||||
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-6 col-md-12 col-lg-3 col-xl-4 p-0 text-md-center text-lg-left">
|
||||
<img
|
||||
alt={intl.formatMessage(messages.certAlt)}
|
||||
className="w-100"
|
||||
src={VerifiedCert}
|
||||
style={{ maxWidth: '10rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-12 col-lg-9 col-xl-8 p-0 pl-lg-2 text-center mt-md-2 mt-lg-0">
|
||||
<div className="row w-100 m-0 justify-content-center">
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
{onLearnMore && (
|
||||
<div className="col-12">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="pb-0"
|
||||
onClick={onLearnMore}
|
||||
aria-labelledby="outline-sidebar-upgrade-header"
|
||||
>
|
||||
{intl.formatMessage(messages.learnMore)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeCard.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLearnMore: PropTypes.func,
|
||||
};
|
||||
|
||||
UpgradeCard.defaultProps = {
|
||||
onLearnMore: null,
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeCard);
|
||||
@@ -1,4 +0,0 @@
|
||||
.outline-sidebar-upgrade-card {
|
||||
border: 1px solid $dark-500;
|
||||
border-top: 5px solid $dark-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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { requestCert } from '../data/thunks';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
import VerifiedCert from '../../generic/assets/edX_certificate.png';
|
||||
|
||||
function CertificateBanner({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
certificateData,
|
||||
enrollmentMode,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
if (certificateData === null || enrollmentMode === 'audit') { return null; }
|
||||
const { certUrl, certDownloadUrl } = certificateData;
|
||||
const dispatch = useDispatch();
|
||||
function requestHandler() {
|
||||
dispatch(requestCert(courseId));
|
||||
}
|
||||
return (
|
||||
<section className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100 row">
|
||||
<div className="col-12 col-sm-9">
|
||||
<div>
|
||||
<div className="font-weight-bold">{certificateData.title}</div>
|
||||
<div className="mt-1">{certificateData.msg}</div>
|
||||
</div>
|
||||
{certUrl && (
|
||||
<div>
|
||||
<a className="btn btn-primary my-3" href={certUrl} rel="noopener noreferrer" target="_blank">
|
||||
{intl.formatMessage(messages.viewCert)}
|
||||
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!certUrl && certificateData.isDownloadable && (
|
||||
<div>
|
||||
<a className="btn btn-primary my-3" href={certDownloadUrl} rel="noopener noreferrer" target="_blank">
|
||||
{intl.formatMessage(messages.downloadCert)}
|
||||
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!certUrl && !certificateData.isDownloadable && certificateData.isRequestable && (
|
||||
<div className="my-3">
|
||||
<button className="btn btn-primary" type="button" onClick={requestHandler}>
|
||||
{intl.formatMessage(messages.requestCert)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-0 col-sm-3 d-none d-sm-block">
|
||||
<img
|
||||
alt={intl.formatMessage(messages.certAlt)}
|
||||
src={VerifiedCert}
|
||||
className="float-right"
|
||||
style={{ height: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateBanner);
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Subsection from './Subsection';
|
||||
|
||||
export default function Chapter({
|
||||
chapter,
|
||||
}) {
|
||||
if (chapter.displayName === 'hidden') { return null; }
|
||||
const { subsections } = chapter;
|
||||
return (
|
||||
<section className="border-top border-light-500">
|
||||
<div className="row">
|
||||
<div className="lead font-weight-normal col-12 col-sm-3 my-3 border-right border-light-500">
|
||||
{chapter.displayName}
|
||||
</div>
|
||||
<div className="col-12 col-sm-9">
|
||||
{subsections.map((subsection) => (
|
||||
<Subsection
|
||||
key={subsection.url}
|
||||
subsection={subsection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Chapter.propTypes = {
|
||||
chapter: PropTypes.shape({
|
||||
displayName: PropTypes.string,
|
||||
subsections: PropTypes.arrayOf(PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
})),
|
||||
}).isRequired,
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
FormattedDate, FormattedTime, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function CreditRequirements({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
creditCourseRequirements,
|
||||
creditSupportUrl,
|
||||
verificationData,
|
||||
userTimezone,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
if (creditCourseRequirements === null) { return null; }
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const eligibility = creditCourseRequirements.eligibilityStatus;
|
||||
let message;
|
||||
switch (eligibility) {
|
||||
case 'not_eligible':
|
||||
message = intl.formatMessage(messages.creditNotEligible);
|
||||
break;
|
||||
case 'eligible':
|
||||
message = intl.formatMessage(messages.creditEligible);
|
||||
break;
|
||||
case 'partial_eligible':
|
||||
message = intl.formatMessage(messages.creditPartialEligible);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const completed = `✓ ${intl.formatMessage(messages.completed)} `;
|
||||
|
||||
const { status } = verificationData;
|
||||
let verificationMessage;
|
||||
let verificationLinkMessage = '';
|
||||
|
||||
switch (status) {
|
||||
case 'none':
|
||||
case 'expired':
|
||||
verificationMessage = `${intl.formatMessage(messages.notStarted)}; `;
|
||||
verificationLinkMessage = intl.formatMessage(messages.notStarted);
|
||||
break;
|
||||
case 'approved':
|
||||
verificationMessage = completed;
|
||||
break;
|
||||
case 'pending':
|
||||
verificationMessage = intl.formatMessage(messages.pending);
|
||||
break;
|
||||
case 'must_reverify':
|
||||
verificationMessage = `${intl.formatMessage(messages.rejected)}; `;
|
||||
verificationLinkMessage = intl.formatMessage(messages.tryAgain);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<section className="banner rounded row border border-primary-300 my-2">
|
||||
<div className="col ml-4 my-3">
|
||||
<div className="row font-weight-bold">
|
||||
{intl.formatMessage(messages.courseCreditHeader)}
|
||||
</div>
|
||||
<div className="row mb-2">{message}</div>
|
||||
{creditCourseRequirements.requirements.map((requirement) => (
|
||||
<div key={requirement.displayName} className="row w-50 border-bottom">
|
||||
<div className="col-4">
|
||||
{requirement.displayName}
|
||||
{requirement.minGrade && (
|
||||
<span>{` ${requirement.minGrade}%`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-8">
|
||||
{!requirement.status && (
|
||||
intl.formatMessage(messages.notMet)
|
||||
)}
|
||||
{(requirement.status === 'failed' || requirement.status === 'declined') && (
|
||||
intl.formatMessage(messages.failed)
|
||||
)}
|
||||
{requirement.status === 'submitted' && (
|
||||
intl.formatMessage(messages.submitted)
|
||||
)}
|
||||
{requirement.status === 'satisfied' && (
|
||||
<span>
|
||||
{completed}
|
||||
{requirement.statusDate && (
|
||||
<span>
|
||||
<FormattedDate
|
||||
value={requirement.statusDate}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/> <FormattedTime
|
||||
value={requirement.statusDate}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="row w-50 border-bottom">
|
||||
<div className="col-4">Verification Status </div>
|
||||
<div className="col-8">
|
||||
{verificationMessage}
|
||||
{verificationLinkMessage && (
|
||||
<a href={verificationData.link}>{verificationLinkMessage}</a>
|
||||
)}
|
||||
{status === 'approved' && verificationData.statusDate && (
|
||||
<span>
|
||||
<FormattedDate
|
||||
value={verificationData.statusDate}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{eligibility === 'eligible' && (
|
||||
<div className="mt-3 row">
|
||||
<a className="btn btn-primary" href={creditCourseRequirements.dashboardUrl}>{intl.formatMessage(messages.purchaseCredit)}</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 row">
|
||||
<a href={creditSupportUrl}>{intl.formatMessage(messages.learnMoreCredit)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CreditRequirements.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CreditRequirements);
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
export default function DueDateTime({
|
||||
due,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('progress', courseId);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<em className="ml-0">
|
||||
due <FormattedDate
|
||||
value={due}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/> <FormattedTime
|
||||
value={due}
|
||||
/>
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
||||
DueDateTime.propTypes = {
|
||||
due: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
function ProblemScores({
|
||||
intl,
|
||||
scoreName,
|
||||
problemScores,
|
||||
}) {
|
||||
return (
|
||||
<div className="row mt-1">
|
||||
<dl className="d-flex flex-wrap small text-gray-500">
|
||||
<dt className="mr-3">{intl.formatMessage(messages[`${scoreName}`])}</dt>
|
||||
{problemScores.map((problem, index) => {
|
||||
const key = scoreName + index;
|
||||
return (
|
||||
<dd className="mr-3" key={key}>{problem.earned}/{problem.possible}</dd>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProblemScores.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
scoreName: PropTypes.string.isRequired,
|
||||
problemScores: PropTypes.arrayOf(PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProblemScores);
|
||||
46
src/course-home/progress-tab/ProgressHeader.jsx
Normal file
46
src/course-home/progress-tab/ProgressHeader.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function ProgressHeader({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
targetUserId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const { administrator, userId } = getAuthenticatedUser();
|
||||
|
||||
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>{pageTitle}</h1>
|
||||
{administrator && studioUrl && (
|
||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProgressHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProgressHeader);
|
||||
@@ -1,48 +1,62 @@
|
||||
import React from 'react';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import Chapter from './Chapter';
|
||||
import CertificateBanner from './CertificateBanner';
|
||||
import messages from './messages';
|
||||
import CreditRequirements from './CreditRequirements';
|
||||
|
||||
function ProgressTab({ intl }) {
|
||||
import CertificateStatus from './certificate-status/CertificateStatus';
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
import CourseGrade from './grades/course-grade/CourseGrade';
|
||||
import DetailedGrades from './grades/detailed-grades/DetailedGrades';
|
||||
import GradeSummary from './grades/grade-summary/GradeSummary';
|
||||
import ProgressHeader from './ProgressHeader';
|
||||
import RelatedLinks from './related-links/RelatedLinks';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function ProgressTab() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const {
|
||||
coursewareSummary,
|
||||
studioUrl,
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
desktop: 992,
|
||||
});
|
||||
|
||||
const OnMobile = layout.is('mobile');
|
||||
const OnDesktop = layout.isAtLeast('desktop');
|
||||
return (
|
||||
<section>
|
||||
{administrator && studioUrl && (
|
||||
<div className="row mb-3 mr-3 justify-content-end">
|
||||
<a className="btn-sm border border-info" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
</a>
|
||||
<>
|
||||
<ProgressHeader />
|
||||
<div className="row w-100 m-0">
|
||||
{/* Main body */}
|
||||
<div className="col-12 col-md-8 p-0">
|
||||
<CourseCompletion />
|
||||
<OnMobile>
|
||||
<CertificateStatus />
|
||||
</OnMobile>
|
||||
<CourseGrade />
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
|
||||
<GradeSummary />
|
||||
<DetailedGrades />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CertificateBanner />
|
||||
<CreditRequirements />
|
||||
{coursewareSummary.map((chapter) => (
|
||||
<Chapter
|
||||
key={chapter.displayName}
|
||||
chapter={chapter}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Side panel */}
|
||||
<div className="col-12 col-md-4 p-0 px-md-4">
|
||||
<OnDesktop>
|
||||
<CertificateStatus />
|
||||
</OnDesktop>
|
||||
<RelatedLinks />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProgressTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProgressTab);
|
||||
export default ProgressTab;
|
||||
|
||||
1200
src/course-home/progress-tab/ProgressTab.test.jsx
Normal file
1200
src/course-home/progress-tab/ProgressTab.test.jsx
Normal file
@@ -0,0 +1,1200 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, act,
|
||||
} from '../../setupTest';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
import * as thunks from '../data/thunks';
|
||||
import initializeStore from '../../store';
|
||||
import ProgressTab from './ProgressTab';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Progress Tab', () => {
|
||||
let axiosMock;
|
||||
|
||||
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 = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/*`);
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('progressTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
function setTabData(attributes, options) {
|
||||
const progressTabData = Factory.build('progressTabData', attributes, options);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
await act(async () => render(<ProgressTab />, { store }));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
// Set defaults for network requests
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
describe('Related links', () => {
|
||||
beforeEach(() => {
|
||||
sendTrackEvent.mockClear();
|
||||
});
|
||||
|
||||
it('sends event on click of dates tab link', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const datesTabLink = screen.getByRole('link', { name: 'Dates' });
|
||||
fireEvent.click(datesTabLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.related_links.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
link_clicked: 'dates',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends event on click of outline tab link', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
|
||||
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.related_links.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
link_clicked: 'course_outline',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Grade', () => {
|
||||
it('renders Course Grade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grades')).toBeInTheDocument();
|
||||
expect(screen.getByText('This represents your weighted grade against the grade needed to pass this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy in CourseGradeFooter for non-passing', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
is_passing: false,
|
||||
letter_grade: null,
|
||||
percent: 0.5,
|
||||
},
|
||||
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: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
percent_graded: 0.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy in CourseGradeFooter for passing with pass/fail grade range', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('You’re currently passing this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy and tooltip in CourseGradeFooter for non-passing with letter grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
is_passing: false,
|
||||
letter_grade: null,
|
||||
percent: 0,
|
||||
},
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy and tooltip in CourseGradeFooter for passing with letter grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
is_passing: true,
|
||||
letter_grade: 'B',
|
||||
percent: 0.8,
|
||||
},
|
||||
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: true,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(await screen.findByText('You’re currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tooltip in CourseGradeFooter for grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
percent: 0,
|
||||
is_passing: false,
|
||||
},
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
const tooltip = await screen.getByRole('button', { name: 'Grade range tooltip' });
|
||||
fireEvent.click(tooltip);
|
||||
expect(screen.getByText('Grade ranges for this course:'));
|
||||
expect(screen.getByText('A: 90%-100%'));
|
||||
expect(screen.getByText('B: 80%-90%'));
|
||||
expect(screen.getByText('F: <80%'));
|
||||
});
|
||||
|
||||
it('renders locked feature preview (CourseGradeHeader) with upgrade button when user has locked content', 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@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();
|
||||
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('link', 'Unlock now')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
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@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();
|
||||
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
|
||||
|
||||
const upgradeButton = screen.getAllByRole('link', 'Unlock now')[0];
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
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 () => {
|
||||
setTabData({
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
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();
|
||||
expect(screen.getByText('The deadline to upgrade in this course has passed.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render locked feature preview when user does not have locked content', async () => {
|
||||
await fetchAndRender();
|
||||
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%.
|
||||
setTabData({
|
||||
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: true,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
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: false,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
// Although the learner's true grade is passing, we should expect this to reflect the grade that's
|
||||
// visible to them, which is non-passing
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grade Summary', () => {
|
||||
it('renders Grade Summary table when assignment policies are populated', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Grade Summary when assignment policies are not populated', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
section_scores: [],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 2,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of droppable assignments is zero', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates weighted grades correctly', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 0.5,
|
||||
},
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 1,
|
||||
short_label: 'Ex',
|
||||
type: 'Exam',
|
||||
weight: 0.5,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct total weighted grade 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%.
|
||||
setTabData({
|
||||
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',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
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: false,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailed Grades', () => {
|
||||
it('renders Detailed Grades table when section scores are populated', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('First subsection'));
|
||||
expect(screen.getByText('Second subsection'));
|
||||
});
|
||||
|
||||
it('sends event on click of subsection link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
const subsectionLink = screen.getByRole('link', { name: 'First subsection' });
|
||||
fireEvent.click(subsectionLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.detailed_grades_assignment.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
assignment_block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends event on click of course outline link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
|
||||
fireEvent.click(outlineLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.detailed_grades.course_outline_link.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
});
|
||||
});
|
||||
|
||||
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: [],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate Status', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => {
|
||||
const matches = !!(query === 'screen and (min-width: 992px)');
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrolled user', () => {
|
||||
beforeEach(async () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
sendTrackEvent.mockClear();
|
||||
});
|
||||
|
||||
it('Displays text for nonPassing case when learner does not have a passing grade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('In order to qualify for a certificate, you must have a passing grade.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting progress tab when learner is not passing', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'not_passing',
|
||||
certificate_status_variant: 'not_passing',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays text for inProgress case when more content is scheduled and the learner does not have a passing grade', async () => {
|
||||
setTabData({
|
||||
has_scheduled_content: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting progress tab when user has scheduled content', async () => {
|
||||
setTabData({
|
||||
has_scheduled_content: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'not_passing',
|
||||
certificate_status_variant: 'has_scheduled_content',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays request certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'requesting' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Request certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and on click of request certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'requesting' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'requesting',
|
||||
});
|
||||
|
||||
const requestCertificateLink = screen.getByRole('button', { name: 'Request certificate' });
|
||||
fireEvent.click(requestCertificateLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'requesting',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays verify identity link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
user_has_passing_grade: true,
|
||||
verification_data: { link: 'test' },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Verify ID' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and on click of ID verification link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
user_has_passing_grade: true,
|
||||
verification_data: { link: 'test' },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'unverified',
|
||||
});
|
||||
|
||||
const idVerificationLink = screen.getByRole('link', { name: 'Verify ID' });
|
||||
fireEvent.click(idVerificationLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'unverified',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays verification pending message', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verification_data: { status: 'pending' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Verify ID' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting progress tab with ID verification pending message', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verification_data: { status: 'pending' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'unverified',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays download link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'earned_downloadable',
|
||||
});
|
||||
|
||||
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
|
||||
fireEvent.click(downloadCertificateLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'earned_downloadable',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays webview link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and on click of view certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'earned_viewable',
|
||||
});
|
||||
|
||||
const viewCertificateLink = screen.getByRole('link', { name: 'View my certificate' });
|
||||
fireEvent.click(viewCertificateLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'earned_viewable',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays certificate is earned but unavailable message', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'earned_but_not_available' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Certificate status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting the progress tab when cert is earned but unavailable', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'earned_but_not_available' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'earned_but_not_available',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends event with correct grade variant for passing with letter grades', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'earned_but_not_available' },
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing_grades',
|
||||
certificate_status_variant: 'earned_but_not_available',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays upgrade link when available', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
verified_mode: {
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
// Keep these text checks in sync with "audit only" test below, so it doesn't end up checking for text that is
|
||||
// never actually there, when/if the text changes.
|
||||
expect(screen.getByText('You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and when audit learner clicks upgrade link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
verified_mode: {
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'not_passing',
|
||||
certificate_status_variant: 'audit_passing',
|
||||
});
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
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 () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
});
|
||||
await fetchAndRender();
|
||||
// Keep these queries in sync with "upgrade link" test above, so we don't end up checking for text that is
|
||||
// never actually there, when/if the text changes.
|
||||
expect(screen.queryByText('You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Upgrade now' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting the progress tab even when audit user cannot upgrade (i.e. certificate component does not render)', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'not_passing',
|
||||
certificate_status_variant: 'audit_passing_missed_upgrade_deadline',
|
||||
});
|
||||
});
|
||||
|
||||
it('Does not display the certificate component if it does not match any statuses', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'bogus_status',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
setMetadata({ is_enrolled: true });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting progress tab, although no certificate statuses match', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'bogus_status',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
setMetadata({ is_enrolled: true });
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'certificate_status_disabled',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Does not display the certificate component if the user is not enrolled', async () => {
|
||||
await fetchAndRender();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
import DueDateTime from './DueDateTime';
|
||||
import ProblemScores from './ProblemScores';
|
||||
|
||||
function Subsection({
|
||||
intl,
|
||||
subsection,
|
||||
}) {
|
||||
const scoreName = subsection.graded ? 'problem' : 'practice';
|
||||
|
||||
const { earned, possible } = subsection.gradedTotal;
|
||||
|
||||
const showTotalScore = ((possible > 0) || (earned > 0)) && subsection.showGrades;
|
||||
|
||||
// screen reader information
|
||||
const totalScoreSr = intl.formatMessage(messages.pointsEarned, { earned, total: possible });
|
||||
|
||||
return (
|
||||
<section className="my-3 ml-3">
|
||||
<div className="row">
|
||||
<a className="h6" href={subsection.url}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: subsection.displayName }} />
|
||||
{showTotalScore && <span className="sr-only">{totalScoreSr}</span>}
|
||||
</a>
|
||||
{showTotalScore && <span className="small ml-1 mb-2">({earned}/{possible}) {subsection.percentGraded}%</span>}
|
||||
</div>
|
||||
<div className="row small">
|
||||
{subsection.format && <div className="mr-1">{subsection.format}</div>}
|
||||
{subsection.due !== null && <DueDateTime due={subsection.due} />}
|
||||
</div>
|
||||
{subsection.problemScores.length > 0 && subsection.showGrades && (
|
||||
<ProblemScores scoreName={scoreName} problemScores={subsection.problemScores} />
|
||||
)}
|
||||
{subsection.problemScores.length > 0 && !subsection.showGrades && subsection.showCorrectness === 'past_due' && (
|
||||
<div className="row small">{intl.formatMessage(messages[`${scoreName}HiddenUntil`])}</div>
|
||||
)}
|
||||
{subsection.problemScores.length > 0 && !subsection.showGrades && !(subsection.showCorrectness === 'past_due')
|
||||
&& <div className="row small">{intl.formatMessage(messages[`${scoreName}Hidden`])}</div>}
|
||||
{(subsection.problemScores.length === 0) && (
|
||||
<div className="row small">{intl.formatMessage(messages.noScores)}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Subsection.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
subsection: PropTypes.shape({
|
||||
graded: PropTypes.bool.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
showGrades: PropTypes.bool.isRequired,
|
||||
gradedTotal: PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
graded: PropTypes.bool,
|
||||
}).isRequired,
|
||||
showCorrectness: PropTypes.string.isRequired,
|
||||
due: PropTypes.string,
|
||||
problemScores: PropTypes.arrayOf(PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
})).isRequired,
|
||||
format: PropTypes.string,
|
||||
// override: PropTypes.object,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
percentGraded: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Subsection);
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
FormattedDate, FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Card } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
|
||||
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
|
||||
import { requestCert } from '../../data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
function CertificateStatus({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
certificateData,
|
||||
end,
|
||||
enrollmentMode,
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
hasScheduledContent,
|
||||
userHasPassingGrade,
|
||||
verificationData,
|
||||
verifiedMode,
|
||||
} = useModel('progress', courseId);
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
let certStatus;
|
||||
let certWebViewUrl;
|
||||
let downloadUrl;
|
||||
|
||||
if (certificateData) {
|
||||
certStatus = certificateData.certStatus;
|
||||
certWebViewUrl = certificateData.certWebViewUrl;
|
||||
downloadUrl = certificateData.downloadUrl;
|
||||
}
|
||||
|
||||
let certCase;
|
||||
let certEventName = certStatus;
|
||||
let body;
|
||||
let buttonAction;
|
||||
let buttonLocation;
|
||||
let buttonText;
|
||||
let endDate;
|
||||
let certAvailabilityDate;
|
||||
|
||||
let gradeEventName = 'not_passing';
|
||||
if (userHasPassingGrade) {
|
||||
gradeEventName = Object.entries(gradeRange).length > 1 ? 'passing_grades' : 'passing';
|
||||
}
|
||||
|
||||
const dashboardLink = <DashboardLink />;
|
||||
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 && !certIsDownloadable) {
|
||||
certCase = 'notPassing';
|
||||
certEventName = 'not_passing';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
} 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 || certIsDownloadable) {
|
||||
switch (certStatus) {
|
||||
case 'requesting':
|
||||
certCase = 'requestable';
|
||||
buttonAction = () => { dispatch(requestCert(courseId)); };
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
|
||||
break;
|
||||
|
||||
case 'unverified':
|
||||
certCase = 'unverified';
|
||||
if (verificationData.status === 'pending') {
|
||||
body = (<p>{intl.formatMessage(messages.unverifiedPendingBody)}</p>);
|
||||
} else {
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="progress.certificateStatus.unverifiedBody"
|
||||
defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}."
|
||||
values={{ idVerificationSupportLink }}
|
||||
/>
|
||||
);
|
||||
buttonLocation = verificationData.link;
|
||||
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'downloadable':
|
||||
// Certificate available, download/viewable
|
||||
certCase = 'downloadable';
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="progress.certificateStatus.downloadableBody"
|
||||
defaultMessage="
|
||||
Showcase your accomplishment on LinkedIn or your resume today.
|
||||
You can download your certificate now and access it any time from your
|
||||
{dashboardLink} and {profileLink}."
|
||||
values={{ dashboardLink, profileLink }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (certWebViewUrl) {
|
||||
certEventName = 'earned_viewable';
|
||||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
buttonText = intl.formatMessage(messages.viewableButton);
|
||||
} else if (downloadUrl) {
|
||||
certEventName = 'earned_downloadable';
|
||||
buttonLocation = downloadUrl;
|
||||
buttonText = intl.formatMessage(messages.downloadableButton);
|
||||
}
|
||||
break;
|
||||
|
||||
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="This course ends on {endDate}. Final grades and certificates are
|
||||
scheduled to be available after {certAvailabilityDate}."
|
||||
values={{ endDate, certAvailabilityDate }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'audit_passing':
|
||||
case 'honor_passing':
|
||||
if (verifiedMode) {
|
||||
certCase = 'upgrade';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
buttonLocation = verifiedMode.upgradeUrl;
|
||||
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
|
||||
} else {
|
||||
certCase = null; // Do not render the certificate component if the upgrade deadline has passed
|
||||
certEventName = 'audit_passing_missed_upgrade_deadline';
|
||||
}
|
||||
break;
|
||||
|
||||
// This code shouldn't be hit but coding defensively since switch expects a default statement
|
||||
default:
|
||||
certCase = null;
|
||||
certEventName = 'no_certificate_status';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Log visit to progress tab
|
||||
useEffect(() => {
|
||||
sendTrackEvent('edx.ui.lms.course_progress.visited', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
track_variant: enrollmentMode,
|
||||
grade_variant: gradeEventName,
|
||||
certificate_status_variant: certEventName,
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!certCase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = intl.formatMessage(messages[`${certCase}Header`]);
|
||||
|
||||
const logCertificateStatusButtonClicked = () => {
|
||||
sendTrackEvent('edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
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 (
|
||||
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
|
||||
<Card className="bg-light-200 shadow-sm border-0">
|
||||
<Card.Body>
|
||||
<Card.Title>
|
||||
<h3>{header}</h3>
|
||||
</Card.Title>
|
||||
<Card.Text className="small text-gray-700">
|
||||
{body}
|
||||
</Card.Text>
|
||||
{buttonText && (buttonLocation || buttonAction) && (
|
||||
<Button
|
||||
variant="outline-brand"
|
||||
onClick={() => {
|
||||
logCertificateStatusButtonClicked(certStatus);
|
||||
if (buttonAction) { buttonAction(); }
|
||||
}}
|
||||
href={buttonLocation}
|
||||
block
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateStatus.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateStatus);
|
||||
90
src/course-home/progress-tab/certificate-status/messages.js
Normal file
90
src/course-home/progress-tab/certificate-status/messages.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
notPassingHeader: {
|
||||
id: 'progress.certificateStatus.notPassingHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
notPassingBody: {
|
||||
id: 'progress.certificateStatus.notPassingBody',
|
||||
defaultMessage: 'In order to qualify for a certificate, you must have a passing grade.',
|
||||
},
|
||||
inProgressHeader: {
|
||||
id: 'progress.certificateStatus.inProgressHeader',
|
||||
defaultMessage: 'More content is coming soon!',
|
||||
},
|
||||
inProgressBody: {
|
||||
id: 'progress.certificateStatus.inProgressBody',
|
||||
defaultMessage: 'It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.',
|
||||
},
|
||||
requestableHeader: {
|
||||
id: 'progress.certificateStatus.requestableHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
requestableBody: {
|
||||
id: 'progress.certificateStatus.requestableBody',
|
||||
defaultMessage: 'Congratulations, you qualified for a certificate! In order to access your certificate, request it below.',
|
||||
},
|
||||
requestableButton: {
|
||||
id: 'progress.certificateStatus.requestableButton',
|
||||
defaultMessage: 'Request certificate',
|
||||
},
|
||||
unverifiedHeader: {
|
||||
id: 'progress.certificateStatus.unverifiedHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
unverifiedButton: {
|
||||
id: 'progress.certificateStatus.unverifiedButton',
|
||||
defaultMessage: 'Verify ID',
|
||||
},
|
||||
unverifiedPendingBody: {
|
||||
id: 'progress.certificateStatus.courseCelebration.verificationPending',
|
||||
defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.',
|
||||
},
|
||||
downloadableHeader: {
|
||||
id: 'progress.certificateStatus.downloadableHeader',
|
||||
defaultMessage: 'Your certificate is available!',
|
||||
},
|
||||
downloadableBody: {
|
||||
id: 'progress.certificateStatus.downloadableBody',
|
||||
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.',
|
||||
},
|
||||
downloadableButton: {
|
||||
id: 'progress.certificateStatus.downloadableButton',
|
||||
defaultMessage: 'Download my certificate',
|
||||
},
|
||||
viewableButton: {
|
||||
id: 'progress.certificateStatus.viewableButton',
|
||||
defaultMessage: 'View my certificate',
|
||||
},
|
||||
notAvailableHeader: {
|
||||
id: 'progress.certificateStatus.notAvailableHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
upgradeHeader: {
|
||||
id: 'progress.certificateStatus.upgradeHeader',
|
||||
defaultMessage: 'Earn a certificate',
|
||||
},
|
||||
upgradeBody: {
|
||||
id: 'progress.certificateStatus.upgradeBody',
|
||||
defaultMessage: 'You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.',
|
||||
},
|
||||
upgradeButton: {
|
||||
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;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
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;
|
||||
let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
|
||||
|
||||
const lockedSegmentOffset = lockedPercentage - 75;
|
||||
if (lockedPercentage > 0) {
|
||||
completeTooltipDegree = (lockedSegmentOffset + completePercentage) * -3.6 + 90 + completeSegmentOffset;
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
className="donut-segment-group"
|
||||
onBlur={() => setShowCompletePopover(false)}
|
||||
onFocus={() => setShowCompletePopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showCompletePopover}
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover aria-hidden="true">
|
||||
<Popover.Content>
|
||||
{intl.formatMessage(messages.completeContentTooltip)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
{/* Used to anchor the tooltip within the complete segment's stroke */}
|
||||
<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 > 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"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
CompleteDonutSegment.propTypes = {
|
||||
completePercentage: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
lockedPercentage: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CompleteDonutSegment);
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import CompleteDonutSegment from './CompleteDonutSegment';
|
||||
import IncompleteDonutSegment from './IncompleteDonutSegment';
|
||||
import LockedDonutSegment from './LockedDonutSegment';
|
||||
import messages from './messages';
|
||||
|
||||
function CompletionDonutChart({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
completeCount,
|
||||
incompleteCount,
|
||||
lockedCount,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const numTotalUnits = completeCount + incompleteCount + lockedCount;
|
||||
const completePercentage = completeCount ? Number(((completeCount / numTotalUnits) * 100).toFixed(0)) : 0;
|
||||
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
|
||||
const incompletePercentage = 100 - completePercentage - lockedPercentage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
|
||||
{/* The radius (or "r" attribute) is based off of a circumference of 100 in order to simplify percentage
|
||||
calculations. The subsequent stroke-dasharray values found in each segment should add up to equal 100
|
||||
in order to wrap around the circle once. */}
|
||||
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
|
||||
<g className="donut-chart-text">
|
||||
<text x="50%" y="50%" className="donut-chart-number">
|
||||
{completePercentage}%
|
||||
</text>
|
||||
<text x="50%" y="50%" className="donut-chart-label">
|
||||
{intl.formatMessage(messages.donutLabel)}
|
||||
</text>
|
||||
</g>
|
||||
<IncompleteDonutSegment incompletePercentage={incompletePercentage} />
|
||||
<LockedDonutSegment lockedPercentage={lockedPercentage} />
|
||||
<CompleteDonutSegment completePercentage={completePercentage} lockedPercentage={lockedPercentage} />
|
||||
</svg>
|
||||
<div className="sr-only">
|
||||
{intl.formatMessage(messages.percentComplete, { percent: completePercentage })}
|
||||
{intl.formatMessage(messages.percentIncomplete, { percent: incompletePercentage })}
|
||||
{lockedPercentage > 0 && (
|
||||
<>
|
||||
{intl.formatMessage(messages.percentLocked, { percent: lockedPercentage })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CompletionDonutChart.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CompletionDonutChart);
|
||||
@@ -0,0 +1,74 @@
|
||||
.donut rect {
|
||||
fill: transparent;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.donut-chart-label {
|
||||
font: {
|
||||
family: $font-family-sans-serif;
|
||||
size: .2rem;
|
||||
weight: $font-weight-normal;
|
||||
}
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.donut-chart-number {
|
||||
font: {
|
||||
family: $font-family-monospace;
|
||||
size: .5rem;
|
||||
weight: $font-weight-bold;
|
||||
}
|
||||
line-height: 1rem;
|
||||
text-anchor: middle;
|
||||
-moz-transform: translateY(-0.6em);
|
||||
-ms-transform: translateY(-0.6em);
|
||||
-webkit-transform: translateY(-0.6em);
|
||||
transform: translateY(-0.6em);
|
||||
}
|
||||
|
||||
.donut-chart-text {
|
||||
fill: $primary-500;
|
||||
-moz-transform: translateY(0.25em);
|
||||
-ms-transform: translateY(0.25em);
|
||||
-webkit-transform: translateY(0.25em);
|
||||
transform: translateY(0.25em);
|
||||
}
|
||||
|
||||
.donut-ring, .donut-segment {
|
||||
stroke-width: 6px;
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.donut-segment-group {
|
||||
cursor: pointer;
|
||||
pointer-events: visibleStroke;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
circle {
|
||||
stroke-width: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.donut-ring, .donut-segment, .donut-hole {
|
||||
&.complete-stroke {
|
||||
stroke: $info-500;
|
||||
}
|
||||
|
||||
&.divider-stroke {
|
||||
stroke-width: 7px;
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
&.incomplete-stroke {
|
||||
stroke: $light-300;
|
||||
}
|
||||
|
||||
&.locked-stroke {
|
||||
stroke: $primary-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import CompletionDonutChart from './CompletionDonutChart';
|
||||
import messages from './messages';
|
||||
|
||||
function CourseCompletion({ intl }) {
|
||||
return (
|
||||
<section className="text-dark-700 mb-4 rounded shadow-sm p-4">
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 col-sm-6 col-md-7 p-0">
|
||||
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.completionBody)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
|
||||
<CompletionDonutChart />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseCompletion.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseCompletion);
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
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;
|
||||
const incompleteTooltipDegree = incompletePercentage < 100 ? incompleteSegmentOffset : 0;
|
||||
|
||||
return (
|
||||
<g
|
||||
className="donut-segment-group"
|
||||
onBlur={() => setShowIncompletePopover(false)}
|
||||
onFocus={() => setShowIncompletePopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-ring incomplete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${incompletePercentage} ${100 - incompletePercentage}`}
|
||||
strokeDashoffset="25"
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showIncompletePopover}
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover aria-hidden="true">
|
||||
<Popover.Content>
|
||||
{intl.formatMessage(messages.incompleteContentTooltip)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
{/* Used to anchor the tooltip within the incomplete segment's stroke */}
|
||||
<rect x="19" y="3" style={{ transform: `rotate(${incompleteTooltipDegree}deg)` }} />
|
||||
</OverlayTrigger>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
IncompleteDonutSegment.propTypes = {
|
||||
incompletePercentage: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(IncompleteDonutSegment);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function LockedDonutSegment({ intl, lockedPercentage }) {
|
||||
const [showLockedPopover, setShowLockedPopover] = useState(false);
|
||||
|
||||
if (!lockedPercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconDegree = lockedPercentage > 8 ? (3.6 * lockedPercentage) / 8 : ((3.6 * lockedPercentage) / 5) * 2;
|
||||
|
||||
return (
|
||||
<g
|
||||
className="donut-segment-group"
|
||||
onBlur={() => setShowLockedPopover(false)}
|
||||
onFocus={() => setShowLockedPopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-segment locked-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${lockedPercentage} ${100 - lockedPercentage}`}
|
||||
strokeDashoffset={lockedPercentage - 75}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showLockedPopover}
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover aria-hidden="true">
|
||||
<Popover.Content>
|
||||
{intl.formatMessage(messages.lockedContentTooltip)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<g
|
||||
width="6"
|
||||
height="21"
|
||||
viewBox="0 0 21 6"
|
||||
style={{
|
||||
transformOrigin: 'center',
|
||||
transform: `rotate(-${iconDegree}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Locked icon */}
|
||||
<path
|
||||
d="M20 8.00002H17V6.21002C17 3.60002 15.09 1.27002 12.49 1.02002C9.51 0.740018 7 3.08002 7 6.00002V8.00002H4V22H20V8.00002ZM12 17C10.9 17 10 16.1 10 15C10 13.9 10.9 13 12 13C13.1 13 14 13.9 14 15C14 16.1 13.1 17 12 17ZM9 8.00002V6.00002C9 4.34002 10.34 3.00002 12 3.00002C13.66 3.00002 15 4.34002 15 6.00002V8.00002H9Z"
|
||||
fill={lockedPercentage > 5 ? 'white' : 'transparent'}
|
||||
style={{ transform: `scale(0.18) translate(5.8em, .7em) rotate(${iconDegree}deg)` }}
|
||||
/>
|
||||
</g>
|
||||
</OverlayTrigger>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
LockedDonutSegment.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
lockedPercentage: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LockedDonutSegment);
|
||||
42
src/course-home/progress-tab/course-completion/messages.js
Normal file
42
src/course-home/progress-tab/course-completion/messages.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
donutLabel: {
|
||||
id: 'progress.completion.donut.label',
|
||||
defaultMessage: 'completed',
|
||||
},
|
||||
completionBody: {
|
||||
id: 'progress.completion.body',
|
||||
defaultMessage: 'This represents how much of the course content you have completed. Note that some content may not yet be released.',
|
||||
},
|
||||
completeContentTooltip: {
|
||||
id: 'progress.completion.tooltip.locked',
|
||||
defaultMessage: 'Content that you have completed.',
|
||||
},
|
||||
courseCompletion: {
|
||||
id: 'progress.completion.header',
|
||||
defaultMessage: 'Course completion',
|
||||
},
|
||||
incompleteContentTooltip: {
|
||||
id: 'progress.completion.tooltip',
|
||||
defaultMessage: 'Content that you have access to and have not completed.',
|
||||
},
|
||||
lockedContentTooltip: {
|
||||
id: 'progress.completion.tooltip.complete',
|
||||
defaultMessage: 'Content that is locked and available only to those who upgrade.',
|
||||
},
|
||||
percentComplete: {
|
||||
id: 'progress.completion.donut.percentComplete',
|
||||
defaultMessage: 'You have completed {percent}% of content in this course.',
|
||||
},
|
||||
percentIncomplete: {
|
||||
id: 'progress.completion.donut.percentIncomplete',
|
||||
defaultMessage: 'You have not completed {percent}% of content in this course that you have access to.',
|
||||
},
|
||||
percentLocked: {
|
||||
id: 'progress.completion.donut.percentLocked',
|
||||
defaultMessage: '{percent}% of content in this course is locked and available only for those who upgrade.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import CourseGradeFooter from './CourseGradeFooter';
|
||||
import CourseGradeHeader from './CourseGradeHeader';
|
||||
import GradeBar from './GradeBar';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function CourseGrade({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
gradesFeatureIsPartiallyLocked,
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const passingGrade = Number((Math.min(...Object.values(gradeRange)) * 100).toFixed(0));
|
||||
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<section className="text-dark-700 my-4 rounded shadow-sm">
|
||||
{(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>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.courseGradeBody)}
|
||||
</p>
|
||||
</div>
|
||||
<GradeBar passingGrade={passingGrade} />
|
||||
</div>
|
||||
<CourseGradeFooter passingGrade={passingGrade} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGrade.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGrade);
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { layoutGenerator } from 'react-break';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { CheckCircle, WarningFilled } from '@edx/paragon/icons';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import GradeRangeTooltip from './GradeRangeTooltip';
|
||||
import messages from '../messages';
|
||||
|
||||
function CourseGradeFooter({ intl, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
letterGrade,
|
||||
},
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
tablet: 768,
|
||||
});
|
||||
|
||||
const OnMobile = layout.is('mobile');
|
||||
const OnAtLeastTablet = layout.isAtLeast('tablet');
|
||||
|
||||
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
|
||||
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
|
||||
|
||||
if (isPassing) {
|
||||
if (hasLetterGrades) {
|
||||
const minGradeRangeCutoff = gradeRange[letterGrade] * 100;
|
||||
const possibleMaxGradeRangeValues = [...Object.values(gradeRange).filter(
|
||||
(grade) => (grade * 100 > minGradeRangeCutoff),
|
||||
)];
|
||||
const maxGradeRangeCutoff = possibleMaxGradeRangeValues.length ? Math.min(...possibleMaxGradeRangeValues) * 100
|
||||
: 100;
|
||||
|
||||
footerText = intl.formatMessage(messages.courseGradeFooterPassingWithGrade, {
|
||||
letterGrade,
|
||||
minGrade: minGradeRangeCutoff.toFixed(0),
|
||||
maxGrade: maxGradeRangeCutoff.toFixed(0),
|
||||
});
|
||||
} else {
|
||||
footerText = intl.formatMessage(messages.courseGradeFooterGenericPassing);
|
||||
}
|
||||
}
|
||||
|
||||
const icon = isPassing ? <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
|
||||
: <Icon src={WarningFilled} className="d-inline-flex align-bottom" />;
|
||||
|
||||
return (
|
||||
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${isPassing ? 'bg-success-100' : 'bg-warning-100'}`}>
|
||||
<div className="col-auto p-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="col-11 pl-2 px-0">
|
||||
<OnMobile>
|
||||
<span className="h5 align-bottom">
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</OnMobile>
|
||||
<OnAtLeastTablet>
|
||||
<span className="h4 m-0 align-bottom">
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</OnAtLeastTablet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGradeFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGradeFooter);
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import messages from '../messages';
|
||||
|
||||
function CourseGradeHeader({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
} = 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', {
|
||||
org_key: org,
|
||||
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`}>
|
||||
<div className="row w-100 m-0 p-0">
|
||||
<div className="col-1 p-0">
|
||||
<Icon src={Locked} />
|
||||
</div>
|
||||
<div className="col-11 px-2 p-sm-0 h4 text-white">
|
||||
<span aria-hidden="true">
|
||||
{intl.formatMessage(messages.courseGradePreviewHeaderAriaHidden)}
|
||||
</span>
|
||||
{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">
|
||||
{previewText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{verifiedMode && (
|
||||
<div className="col-12 col-md-3 mt-3 mt-md-0 p-0 align-self-center text-right">
|
||||
<Button variant="brand" size="sm" href={verifiedMode.upgradeUrl} onClick={logUpgradeButtonClick}>
|
||||
{intl.formatMessage(messages.courseGradePreviewUpgradeButton)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGradeHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGradeHeader);
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function CurrentGradeTooltip({ intl, tooltipClassName }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
show
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
|
||||
<Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}>
|
||||
{currentGrade.toFixed(0)}%
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<g>
|
||||
<circle cx={`${Math.min(...[currentGrade, 100])}%`} cy="50%" r="8.5" fill="transparent" />
|
||||
<rect className="grade-bar__divider" x={`${Math.min(...[currentGrade, 100])}%`} style={{ transform: 'translateY(2.61em)' }} />
|
||||
</g>
|
||||
</OverlayTrigger>
|
||||
<text
|
||||
className="x-small"
|
||||
textAnchor={currentGrade < 50 ? 'start' : 'end'}
|
||||
x={`${Math.min(...[currentGrade, 100])}%`}
|
||||
y="20px"
|
||||
style={{ transform: `translateX(${currentGrade < 50 ? '' : '-'}3.4em)` }}
|
||||
>
|
||||
{intl.formatMessage(messages.currentGradeLabel)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CurrentGradeTooltip.defaultProps = {
|
||||
tooltipClassName: '',
|
||||
};
|
||||
|
||||
CurrentGradeTooltip.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
tooltipClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(CurrentGradeTooltip);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user