Compare commits
419 Commits
open-relea
...
mikix/mfe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d67042660 | ||
|
|
915f521976 | ||
|
|
903d8d4cfb | ||
|
|
f2f4f5f3a5 | ||
|
|
28d359e715 | ||
|
|
d93df0e06f | ||
|
|
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 | ||
|
|
45a68973b7 | ||
|
|
ab98cca421 | ||
|
|
413b189293 | ||
|
|
774b7bb1fc | ||
|
|
ff93d7f4d4 | ||
|
|
e3d9ff9ed3 | ||
|
|
6e2294e279 | ||
|
|
c17beeb908 | ||
|
|
5c65627582 | ||
|
|
8c0cafafa1 | ||
|
|
3450570d7e | ||
|
|
c650283446 | ||
|
|
28773ce4c2 | ||
|
|
e4b1d8088a | ||
|
|
1dc0669bae | ||
|
|
58eb9fe23c | ||
|
|
38617c827e | ||
|
|
a9939a1b5e | ||
|
|
2525805aac | ||
|
|
26a7b3b0de | ||
|
|
4bbc29591c | ||
|
|
49bfc65a03 | ||
|
|
d017c3194e | ||
|
|
a2ccedcecd | ||
|
|
3a7c455bb3 | ||
|
|
19087417b4 | ||
|
|
05c6878644 | ||
|
|
7e2f495f52 | ||
|
|
50e649daa3 | ||
|
|
629382f719 | ||
|
|
8835a9cd6a | ||
|
|
3e2eebdd9b | ||
|
|
acd2cc3222 | ||
|
|
9ef3787d4b | ||
|
|
47fd6bfe18 | ||
|
|
984010a8ec | ||
|
|
58543a34b3 | ||
|
|
293dc9f4c3 | ||
|
|
68c8d31dd1 | ||
|
|
96ef87886f | ||
|
|
48e3f43062 | ||
|
|
d74557d681 | ||
|
|
0e18e0908a | ||
|
|
958c13ca93 | ||
|
|
26de2cebeb | ||
|
|
bd8496a5e2 | ||
|
|
aa56239f54 | ||
|
|
a0b85111eb | ||
|
|
07b252ecc6 | ||
|
|
92b364e0f8 | ||
|
|
9247eb3098 | ||
|
|
9e0f5d7e22 | ||
|
|
db4b4d18cc | ||
|
|
92a464b2da | ||
|
|
7fed8db02a | ||
|
|
8161f4d9a0 | ||
|
|
c6661e71b1 | ||
|
|
6dc7ff761a | ||
|
|
de4a7d9f34 | ||
|
|
5f239583fd | ||
|
|
aff49aa8a9 | ||
|
|
6e10bffd40 | ||
|
|
37a4dcce18 | ||
|
|
c16da21602 | ||
|
|
55ea84f9a6 | ||
|
|
4341a828db | ||
|
|
255e36baa8 | ||
|
|
2fc4c8c153 | ||
|
|
79f5c7fcf4 | ||
|
|
743621ff51 | ||
|
|
9272498c9e | ||
|
|
e89aef78b5 | ||
|
|
3d41c56a0a | ||
|
|
e4060b7481 | ||
|
|
4e92053151 | ||
|
|
38700499d4 | ||
|
|
0e3fc032ab | ||
|
|
a604e0be10 | ||
|
|
264f36b89e | ||
|
|
2319a7dfb0 | ||
|
|
6549e2b8a2 | ||
|
|
ddfc88dad6 | ||
|
|
e1e3d4992d | ||
|
|
43b0f2fbf0 | ||
|
|
64ff72faa9 | ||
|
|
cc0af77e2f | ||
|
|
7086bdc9ab | ||
|
|
e627fd6f27 | ||
|
|
2ba9440966 | ||
|
|
cd51206462 | ||
|
|
0cb97db7eb | ||
|
|
421b438569 | ||
|
|
1d40baf4cd | ||
|
|
3c5fb46a4d | ||
|
|
5f06d726f7 | ||
|
|
bcd69f5836 | ||
|
|
654fd4c35c | ||
|
|
b98a87c1f5 | ||
|
|
0d29082793 | ||
|
|
2d56bc5953 | ||
|
|
1bfe3f4436 | ||
|
|
3f9f40800a | ||
|
|
a1c7969477 | ||
|
|
53cf637938 | ||
|
|
329bcba31c | ||
|
|
122cef6053 | ||
|
|
a8a8cf5862 | ||
|
|
74149c2c54 | ||
|
|
15975fdd78 | ||
|
|
99f0a4a208 | ||
|
|
4f9cd060be | ||
|
|
cd1d3dd379 | ||
|
|
b08f3d7b45 | ||
|
|
1531f3e912 | ||
|
|
6f415544be | ||
|
|
4eb52a592d | ||
|
|
b4823b90e7 | ||
|
|
5602c0a3b3 | ||
|
|
e0e53f24f1 | ||
|
|
9d8c687e4d | ||
|
|
aeb6a3ebb4 | ||
|
|
e2aa00b16d | ||
|
|
ac711d5f3d | ||
|
|
9d8b5d21b5 | ||
|
|
15ae6d4981 | ||
|
|
f063495cbb | ||
|
|
c5821faee8 | ||
|
|
f83a6e574c | ||
|
|
03661ccf4b | ||
|
|
2d5af74b1b | ||
|
|
ae8141c1a8 | ||
|
|
e9cf5e58de | ||
|
|
1950fe56bd | ||
|
|
94cacb14e7 | ||
|
|
a8dea78e24 | ||
|
|
8adcfb040a | ||
|
|
4f396737e4 | ||
|
|
36f8dd81cd | ||
|
|
f6aebc7d29 | ||
|
|
8a63aef3f0 | ||
|
|
4be37ceb14 | ||
|
|
d123fe6229 | ||
|
|
2f738fdba4 | ||
|
|
d52aa3246e | ||
|
|
e6e5258e5b | ||
|
|
753925ba99 | ||
|
|
684be8c0cf | ||
|
|
42715d3de2 | ||
|
|
4d21633462 | ||
|
|
f91abd319f | ||
|
|
43be11c636 | ||
|
|
72df79b9b8 | ||
|
|
6f331ea6d5 | ||
|
|
d137d5682d | ||
|
|
ea9f5254b7 | ||
|
|
577a19e35c | ||
|
|
f58d405b3b | ||
|
|
927d424d33 | ||
|
|
25e5d39a72 | ||
|
|
d8ed3d6bf8 | ||
|
|
c5a43524a1 | ||
|
|
adfc2d568b | ||
|
|
4c6797c631 | ||
|
|
e2710f6ed3 | ||
|
|
1b859f4ab6 | ||
|
|
f44ce4c311 | ||
|
|
d8dbbaa7a2 | ||
|
|
ddc85f2fd3 | ||
|
|
9cbe0b7c8b | ||
|
|
c83389e7c5 | ||
|
|
37d56b4197 | ||
|
|
01f69e2273 | ||
|
|
c5ada7e974 | ||
|
|
450d1c1861 | ||
|
|
aa10b3f600 | ||
|
|
b65bd0ff44 | ||
|
|
86d28136de | ||
|
|
707fcc2aa1 | ||
|
|
caabf6a54c | ||
|
|
d910d09e00 | ||
|
|
de53ed9258 | ||
|
|
81188ae30f | ||
|
|
f5d361661f | ||
|
|
c298bc1dbf | ||
|
|
0ad80a63cf | ||
|
|
bc76adf8eb | ||
|
|
cc7142e5c1 | ||
|
|
a975b8ae70 | ||
|
|
3b34a87391 | ||
|
|
453f56c7c8 | ||
|
|
a131a9f9fb | ||
|
|
d5f9af1954 | ||
|
|
a44d2633a1 | ||
|
|
de2a46eb93 | ||
|
|
9a315aa29d | ||
|
|
ad74b2295b | ||
|
|
c8961d3777 | ||
|
|
c7c401e385 | ||
|
|
9e0f192ae7 | ||
|
|
4667535c0c | ||
|
|
a1646c5793 | ||
|
|
0e6f64081d | ||
|
|
8e4f0535a7 | ||
|
|
6f14f01dc2 | ||
|
|
6233f16812 | ||
|
|
a60480ec52 | ||
|
|
eba9d9a44d | ||
|
|
8cd3d6501f | ||
|
|
76fc6d64f2 | ||
|
|
314b82d0b2 | ||
|
|
e17b66851e | ||
|
|
9accacd019 | ||
|
|
a9cec0102b | ||
|
|
e4d6d37c4e | ||
|
|
7ad501d73b | ||
|
|
f1d43b18d6 | ||
|
|
d408986682 | ||
|
|
8d7fbb5bd8 | ||
|
|
7c046870e3 | ||
|
|
0c5fd44d13 | ||
|
|
f3d23abe84 | ||
|
|
6a44d018d8 | ||
|
|
3362047bcc | ||
|
|
c1bf77efa4 | ||
|
|
e6443ae3bd | ||
|
|
6f7ec81197 | ||
|
|
8719fad091 | ||
|
|
ec7f532bc9 | ||
|
|
0c8389e244 | ||
|
|
cd8f3072e2 | ||
|
|
b048ca8187 | ||
|
|
71482f1ec7 | ||
|
|
7a108728c0 | ||
|
|
d7f41fd02a | ||
|
|
0e7bccef0b | ||
|
|
5ac49610da | ||
|
|
175675da55 | ||
|
|
f715fd5ed6 | ||
|
|
cdab8959ca | ||
|
|
be0ee18519 | ||
|
|
c96cd87967 | ||
|
|
854020dd67 | ||
|
|
d320c6b5bc | ||
|
|
81c6b401fd | ||
|
|
16bd20e0e8 | ||
|
|
23ea255674 | ||
|
|
dec5340bf3 | ||
|
|
086b5d8986 | ||
|
|
b940901400 | ||
|
|
afb4b77250 | ||
|
|
bc30b20b0d | ||
|
|
3e14b17271 | ||
|
|
c0d0895630 | ||
|
|
c2f4ba3ad0 | ||
|
|
eac9bf9c92 | ||
|
|
5332be8e65 | ||
|
|
e04f588d1f | ||
|
|
fe013f57c5 | ||
|
|
7df50264cf | ||
|
|
73c74119f0 | ||
|
|
a6edc9132f | ||
|
|
8b34f8c792 | ||
|
|
534b9b205f | ||
|
|
1471abe7dd | ||
|
|
d55c8b134c | ||
|
|
6c052a2661 | ||
|
|
ac47454b14 | ||
|
|
a18adc4112 | ||
|
|
6cdd075243 | ||
|
|
eb8c97ee86 | ||
|
|
24051232af | ||
|
|
dee5128448 | ||
|
|
5ffc1bc599 | ||
|
|
bf0d3b1565 | ||
|
|
8df4654cf1 | ||
|
|
973f3d68aa | ||
|
|
b51809fa50 | ||
|
|
253836fa9f | ||
|
|
65173e9f93 | ||
|
|
a8d01c423d | ||
|
|
025f37cd21 | ||
|
|
964bde180a | ||
|
|
209a64c29b | ||
|
|
bdb1afe990 | ||
|
|
7487d8d32f |
51
.env
51
.env
@@ -1,18 +1,35 @@
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
INSIGHTS_BASE_URL=
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=null
|
||||
ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
STUDIO_BASE_URL=
|
||||
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,18 +1,35 @@
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
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'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='edX'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
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'
|
||||
22
.env.test
22
.env.test
@@ -1,18 +1,34 @@
|
||||
NODE_ENV='test'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
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'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='edX'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
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
|
||||
|
||||
10
.eslintrc.js
10
.eslintrc.js
@@ -1,3 +1,11 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
module.exports = createConfig('eslint', {
|
||||
overrides: [{
|
||||
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
|
||||
rules: {
|
||||
'import/named': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
21
.github/workflows/validate.yml
vendored
Normal file
21
.github/workflows/validate.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: validate
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 12
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,7 +12,10 @@ temp/babel-plugin-react-intl
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
### Emacs ###
|
||||
### Editors ###
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
|
||||
# Local package dependencies
|
||||
module.config.js
|
||||
|
||||
15
.travis.yml
15
.travis.yml
@@ -1,15 +0,0 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
before_install:
|
||||
- npm install -g npm@6
|
||||
install:
|
||||
- npm ci
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
- npm run is-es5
|
||||
after_success:
|
||||
- codecov
|
||||
14
Makefile
14
Makefile
@@ -52,3 +52,17 @@ pull_translations:
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
.PHONY: validate
|
||||
validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run is-es5
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
npm ci
|
||||
make validate
|
||||
|
||||
53
README.rst
53
README.rst
@@ -1,4 +1,4 @@
|
||||
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|
||||
frontend-app-learning
|
||||
=========================
|
||||
@@ -10,8 +10,6 @@ Introduction
|
||||
|
||||
React app for edX learning.
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://travis-ci.org/edx/frontend-app-learning
|
||||
.. |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
|
||||
@@ -20,3 +18,52 @@ React app for edX learning.
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
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.
|
||||
|
||||
Local module development
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance::
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
Modules you want to use from local source code. Adding a module here means that when this app
|
||||
runs its build, it'll resolve the source from peer directories of this app.
|
||||
|
||||
moduleName: the name you use to import code from the module.
|
||||
dir: The relative path to the module's source code.
|
||||
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
|
||||
may want to use "src" if the module installs React as a peer/dev dependency.
|
||||
*/
|
||||
localModules: [
|
||||
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,4 +4,6 @@ Because we have a variety of models in this app (course, section, sequence, unit
|
||||
|
||||
https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
|
||||
|
||||
(As an additional data point, djoy has stored data in this format in multiple projects over the years and found it to be very effective)
|
||||
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
||||
|
||||
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.courseware.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
||||
|
||||
24
docs/decisions/0006-thunk-and-api-naming.md
Normal file
24
docs/decisions/0006-thunk-and-api-naming.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Naming API functions and redux thunks
|
||||
|
||||
Because API functions and redux thunks are two parts of a larger process, we've informally settled on some naming conventions for them to help differentiate the type of code we're looking at.
|
||||
|
||||
## API Functions
|
||||
|
||||
This micro-frontend follows a pattern of naming API functions with a prefix for their HTTP verb.
|
||||
|
||||
Examples:
|
||||
|
||||
`getCourseBlocks` - The GET request we make to load course blocks data.
|
||||
`postSequencePosition` - The POST request for saving sequence position.
|
||||
|
||||
## Redux Thunks
|
||||
|
||||
Meanwhile, we use a different set of verbs for redux thunks to differentiate them from the API functions. For instance, we use the `fetch` prefix for loading data (primarily via GET requests), and `save` for sending data back to the server (primarily via POST or PATCH requests)
|
||||
|
||||
Examples:
|
||||
|
||||
`fetchCourse` - The thunk for getting course data across several APIs.
|
||||
`fetchSequence` - The thunk for the process of retrieving sequence data.
|
||||
`saveSequencePosition` - Wraps the POST request for sending sequence position back to the server.
|
||||
|
||||
The verb prefixes for thunks aren't perfect - but they're a little more 'friendly' and semantically meaningful than the HTTP verbs used for APIs. So far we have `fetch`, `save`, `check`, `reset`, etc.
|
||||
66
docs/decisions/0007-testing.md
Normal file
66
docs/decisions/0007-testing.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Testing
|
||||
|
||||
## Status
|
||||
Draft
|
||||
|
||||
Let's live with this a bit longer before deciding it's a solid approach and marking this Approved.
|
||||
|
||||
## Context
|
||||
We'd like to all be on the same page about how to approach testing, what is
|
||||
worth testing, and how to do it.
|
||||
|
||||
## React Testing Library
|
||||
We'll use react-testing-library and jest as the main testing tools.
|
||||
|
||||
This has some implications about how to test. You can read the React Testing Library's
|
||||
[Guiding Principles](https://testing-library.com/docs/guiding-principles), but the main
|
||||
takeaway is that you should be interacting with React as closely as possible to the way
|
||||
the user will interact with it.
|
||||
|
||||
For example, they discourage using class or element name selectors to find components
|
||||
during a test. Instead, you should find them by user-oriented attributes like labels,
|
||||
text, or roles. As a last resort, by a `data-testid` tag.
|
||||
|
||||
## Mocking data
|
||||
We'll use [Rosie](https://github.com/rosiejs/rosie) as a tool for building JavaScript objects.
|
||||
Our main use case for Rosie is to use factories in order to mock the data we'd like to fetch when rendering components.
|
||||
[axios-mock-adapter](https://www.npmjs.com/package/axios-mock-adapter) allows us to mock the response of an HTTP request.
|
||||
|
||||
For example, we may use a factory to build a course metadata object:
|
||||
|
||||
`const courseMetadata = Factory.build('courseMetadata');`
|
||||
|
||||
Then we'd pass that `courseMetadata` object into an axios mock call:
|
||||
|
||||
`axiosMock.onGet('example.com').reply(200, courseMetadata);`
|
||||
|
||||
This way, when a component sends a GET request to `example.com` within the test's lifecycle, the request will be intercepted
|
||||
by the axios-mock-adapter, and the courseMetadata object will be returned.
|
||||
|
||||
These factories should live within the data directories they intend to mock
|
||||
```
|
||||
courseware
|
||||
| data
|
||||
| __factories__
|
||||
| courseMetadata.factory.js /* used to define the Rosie factory */
|
||||
| api.js /* getCourseMetadata() lives here */
|
||||
```
|
||||
|
||||
## What to Test
|
||||
We have not found exhaustive unit testing of frontend code to be worth the trouble.
|
||||
Rather, let's focus on testing non-obvious behavior.
|
||||
|
||||
In essence: `test behavior that wouldn't present itself to a developer playing around`.
|
||||
|
||||
Practically speaking, this means error states, interactive components, corner cases,
|
||||
or anything that wouldn't come up in a demo course. Something a developer wouldn't
|
||||
notice in the normal course of working in devstack.
|
||||
|
||||
## Snapshots
|
||||
In practice, we've found snapshots of component trees to be too brittle to be worth it,
|
||||
as refactors occur or external libraries change.
|
||||
|
||||
They can still be useful for data (like redux tests) or tiny isolated components.
|
||||
|
||||
But please avoid for any "interesting" component. Prefer inspecting the explicit behavior
|
||||
under test, rather than just snapshotting the entire component tree.
|
||||
90
docs/decisions/0008-liberal-courseware-path-handling.md
Normal file
90
docs/decisions/0008-liberal-courseware-path-handling.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,11 +1,12 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
});
|
||||
|
||||
18506
package-lock.json
generated
18506
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
71
package.json
71
package.json
@@ -15,6 +15,7 @@
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"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 .",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
@@ -34,34 +35,50 @@
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "^10.0.6",
|
||||
"@edx/frontend-component-header": "^2.0.3",
|
||||
"@edx/frontend-platform": "^1.3.1",
|
||||
"@edx/paragon": "^7.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.8",
|
||||
"@reduxjs/toolkit": "^1.2.3",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "^3.6.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"redux": "^4.0.5",
|
||||
"regenerator-runtime": "^0.13.3"
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.1.5",
|
||||
"@edx/frontend-enterprise": "4.2.3",
|
||||
"@edx/frontend-lib-special-exams": "1.9.0",
|
||||
"@edx/frontend-platform": "1.11.0",
|
||||
"@edx/paragon": "15.2.2",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
||||
"@fortawesome/react-fontawesome": "0.1.14",
|
||||
"@reduxjs/toolkit": "1.3.6",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"js-cookie": "2.2.1",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.13.1",
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-helmet": "6.0.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",
|
||||
"reselect": "4.0.0",
|
||||
"truncate-html": "1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "^3.0.0",
|
||||
"codecov": "^3.6.1",
|
||||
"es-check": "^5.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"husky": "^3.1.0",
|
||||
"jest": "^24.9.0",
|
||||
"reactifex": "^1.1.1"
|
||||
"@edx/frontend-build": "5.5.5",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.10.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.0.17",
|
||||
"axios-mock-adapter": "1.18.2",
|
||||
"codecov": "3.8.2",
|
||||
"es-check": "5.1.4",
|
||||
"glob": "7.1.7",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"jest-chain": "1.1.5",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Course | edX</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
function AccessExpirationAlert(props) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = props;
|
||||
return rawHtml && (
|
||||
<Alert type="info">
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlert;
|
||||
@@ -1,28 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { UserMessagesContext } from '../user-messages';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export function useAccessExpirationAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
const rawHtml = (course && course.courseExpiredMessage) || null;
|
||||
useEffect(() => {
|
||||
if (rawHtml && alertId === null) {
|
||||
setAlertId(add({
|
||||
code: 'clientAccessExpirationAlert',
|
||||
topic: 'course',
|
||||
rawHtml,
|
||||
}));
|
||||
} else if (!rawHtml && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [alertId, courseId, rawHtml]);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as AccessExpirationAlert } from './AccessExpirationAlert';
|
||||
export { useAccessExpirationAlert } from './hooks';
|
||||
160
src/alerts/access-expiration-alert/AccessExpirationAlert.jsx
Normal file
160
src/alerts/access-expiration-alert/AccessExpirationAlert.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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, 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 */
|
||||
const [showMMP2P, setShowMMP2P] = useState(!!window.experiment__home_alert_bShowMMP2P);
|
||||
if (window.experiment__home_alert_showMMP2P === undefined) {
|
||||
window.experiment__home_alert_showMMP2P = (val) => {
|
||||
window.experiment__home_alert_bShowMMP2P = !!val;
|
||||
setShowMMP2P(!!val);
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
} = payload;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
masqueradingExpiredCourse,
|
||||
upgradeDeadline,
|
||||
upgradeUrl,
|
||||
} = accessExpiration;
|
||||
|
||||
if (masqueradingExpiredCourse) {
|
||||
return (
|
||||
<AccessExpirationAlertMasquerade payload={payload} />
|
||||
);
|
||||
}
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
if (showMMP2P) {
|
||||
return (
|
||||
<AccessExpirationAlertMMP2P payload={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 = (
|
||||
<>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.deadline"
|
||||
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationUpgradeDeadline"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={upgradeDeadline}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
defaultMessage="Audit Access Expires {date}"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationHeaderDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.body"
|
||||
defaultMessage="You lose all access to this course, including your progress, on {date}."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationBodyDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{deadlineMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
upgradeUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
analyticsPageName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessExpirationAlert);
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
|
||||
function AccessExpirationAlertMMP2P({ payload }) {
|
||||
const {
|
||||
accessExpiration,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
upgradeDeadline,
|
||||
upgradeUrl,
|
||||
} = accessExpiration;
|
||||
|
||||
let deadlineMessage = null;
|
||||
const formatDate = (val, key) => (
|
||||
<FormattedDate
|
||||
key={`accessExpiration.${key}`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={val}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
if (upgradeDeadline && upgradeUrl) {
|
||||
deadlineMessage = (
|
||||
<>
|
||||
Upgrade by {formatDate(upgradeDeadline, 'upgradeDesc')} to unlock unlimited access to all course activities, including graded assignments.
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
>
|
||||
{messages.upgradeNow.defaultMessage}
|
||||
</Hyperlink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<span className="font-weight-bold">
|
||||
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
|
||||
</span>
|
||||
<br />
|
||||
{deadlineMessage}
|
||||
<br />
|
||||
You lose all access to the first two weeks of scheduled content
|
||||
on {formatDate(expirationDate, 'expirationBody')}.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlertMMP2P.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
upgradeUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessExpirationAlertMMP2P);
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
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 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.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlertMasquerade;
|
||||
42
src/alerts/access-expiration-alert/hooks.js
Normal file
42
src/alerts/access-expiration-alert/hooks.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
const AccessExpirationAlertMasquerade = React.lazy(() => import('./AccessExpirationAlertMasquerade'));
|
||||
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
const payload = {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
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
src/alerts/access-expiration-alert/index.js
Normal file
1
src/alerts/access-expiration-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default, useAccessExpirationAlertMasquerade } from './hooks';
|
||||
10
src/alerts/access-expiration-alert/messages.js
Normal file
10
src/alerts/access-expiration-alert/messages.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.accessExpiration.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
68
src/alerts/enrollment-alert/EnrollmentAlert.jsx
Normal file
68
src/alerts/enrollment-alert/EnrollmentAlert.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
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';
|
||||
|
||||
function EnrollmentAlert({ intl, payload }) {
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
extraText,
|
||||
isStaff,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
org,
|
||||
intl.formatMessage(messages.success),
|
||||
);
|
||||
|
||||
let text = intl.formatMessage(messages.alert);
|
||||
let type = ALERT_TYPES.ERROR;
|
||||
if (isStaff) {
|
||||
text = intl.formatMessage(messages.staffAlert);
|
||||
type = ALERT_TYPES.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}>
|
||||
{intl.formatMessage(messages.enrollNowSentence)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type={type}>
|
||||
{text}
|
||||
{' '}
|
||||
{button}
|
||||
{' '}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
canEnroll: PropTypes.bool,
|
||||
courseId: PropTypes.string,
|
||||
extraText: PropTypes.string,
|
||||
isStaff: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EnrollmentAlert);
|
||||
67
src/alerts/enrollment-alert/hooks.js
Normal file
67
src/alerts/enrollment-alert/hooks.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, {
|
||||
useContext, useState, useCallback, 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 { useModel } from '../../generic/model-store';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const course = useModel('courseHomeMeta', courseId);
|
||||
const outline = useModel('outline', courseId);
|
||||
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. the user is not enrolled,
|
||||
* 2. the user is authenticated, AND
|
||||
* 3. the course is private.
|
||||
*/
|
||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
||||
const payload = {
|
||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||
courseId,
|
||||
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
||||
isStaff: course && course.isStaff,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientEnrollmentAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
1
src/alerts/enrollment-alert/index.js
Normal file
1
src/alerts/enrollment-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { useEnrollmentAlert as default } from './hooks';
|
||||
@@ -1,22 +1,28 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learning.enrollment.alert': {
|
||||
alert: {
|
||||
id: 'learning.enrollment.alert',
|
||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
||||
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.staff.enrollment.alert': {
|
||||
staffAlert: {
|
||||
id: 'learning.staff.enrollment.alert',
|
||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.enrollment.enroll.now': {
|
||||
id: 'learning.enrollment.enroll.now',
|
||||
defaultMessage: 'Enroll Now',
|
||||
enrollNowInline: {
|
||||
id: 'learning.enrollment.enrollNow.Inline',
|
||||
defaultMessage: 'Enroll now',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.'
|
||||
+ 'This text is meant to be used at the beginning of a sentence (example: Enroll now to view course content.)',
|
||||
},
|
||||
enrollNowSentence: {
|
||||
id: 'learning.enrollment.enrollNow.Sentence',
|
||||
defaultMessage: 'Enroll now.',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
||||
},
|
||||
'learning.enrollment.success': {
|
||||
success: {
|
||||
id: 'learning.enrollment.success',
|
||||
defaultMessage: "You've successfully enrolled in this course!",
|
||||
description: 'A message telling the user that their course enrollment was successful.',
|
||||
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,23 +2,30 @@ 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 } from '../user-messages';
|
||||
import messages from './messages';
|
||||
import { Alert } from '../../generic/user-messages';
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
function LogistrationAlert({ intl }) {
|
||||
const signIn = (
|
||||
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
|
||||
{intl.formatMessage(messages['learning.logistration.login'])}
|
||||
</a>
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.signInLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
// TODO: Pull this registration URL building out into a function, like the login one above.
|
||||
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
|
||||
const register = (
|
||||
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
|
||||
{intl.formatMessage(messages['learning.logistration.register'])}
|
||||
</a>
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.registerLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -26,7 +33,7 @@ function LogistrationAlert({ intl }) {
|
||||
<FormattedMessage
|
||||
id="learning.logistration.alert"
|
||||
description="Prompts the user to sign in or register to see course content."
|
||||
defaultMessage="Please {signIn} or {register} to see course content."
|
||||
defaultMessage="To see course content, {signIn} or {register}."
|
||||
values={{
|
||||
signIn,
|
||||
register,
|
||||
28
src/alerts/logistration-alert/hooks.js
Normal file
28
src/alerts/logistration-alert/hooks.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useContext } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
|
||||
|
||||
export function useLogistrationAlert(courseId) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const outline = useModel('outline', courseId);
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. the user is not authenticated, AND
|
||||
* 2. the course is private.
|
||||
*/
|
||||
const isVisible = authenticatedUser === null && privateOutline;
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientLogistrationAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
});
|
||||
|
||||
return { clientLogistrationAlert: LogistrationAlert };
|
||||
}
|
||||
1
src/alerts/logistration-alert/index.js
Normal file
1
src/alerts/logistration-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { useLogistrationAlert as default } from './hooks';
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
34
src/course-header/AnonymousUserMenu.jsx
Normal file
34
src/course-header/AnonymousUserMenu.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import genericMessages from '../generic/messages';
|
||||
|
||||
function AnonymousUserMenu({ intl }) {
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
className="mr-3"
|
||||
variant="outline-primary"
|
||||
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.registerSentenceCase)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
href={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.signInSentenceCase)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AnonymousUserMenu.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AnonymousUserMenu);
|
||||
74
src/course-header/AuthenticatedUserDropdown.jsx
Normal file
74
src/course-header/AuthenticatedUserDropdown.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username }) {
|
||||
let dashboardMenuItem = (
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||
{intl.formatMessage(messages.dashboard)}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
|
||||
dashboardMenuItem = (
|
||||
<Dropdown.Item
|
||||
href={enterpriseLearnerPortalLink.href}
|
||||
>
|
||||
{enterpriseLearnerPortalLink.content}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Toggle variant="outline-primary">
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span data-hj-suppress className="d-none d-md-inline">
|
||||
{username}
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
{dashboardMenuItem}
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
|
||||
{intl.formatMessage(messages.profile)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
|
||||
{intl.formatMessage(messages.account)}
|
||||
</Dropdown.Item>
|
||||
{!enterpriseLearnerPortalLink && (
|
||||
// Users should only see Order History if they do not have an available
|
||||
// learner portal, because an available learner portal currently means
|
||||
// that they access content via Subscriptions, in which context an "order"
|
||||
// is not relevant.
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
|
||||
{intl.formatMessage(messages.orderHistory)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>
|
||||
{intl.formatMessage(messages.signOut)}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AuthenticatedUserDropdown.propTypes = {
|
||||
enterpriseLearnerPortalLink: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AuthenticatedUserDropdown.defaultProps = {
|
||||
enterpriseLearnerPortalLink: '',
|
||||
};
|
||||
|
||||
export default injectIntl(AuthenticatedUserDropdown);
|
||||
@@ -4,7 +4,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../tabs/Tabs';
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTabSlug, className, tabs, intl,
|
||||
@@ -14,7 +14,7 @@ function CourseTabsNavigation({
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
@@ -36,7 +36,6 @@ CourseTabsNavigation.propTypes = {
|
||||
className: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
priority: PropTypes.number.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
|
||||
34
src/course-header/CourseTabsNavigation.test.jsx
Normal file
34
src/course-header/CourseTabsNavigation.test.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp, render, screen } from '../setupTest';
|
||||
import { CourseTabsNavigation } from './index';
|
||||
|
||||
describe('Course Tabs Navigation', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('renders without tabs', () => {
|
||||
render(<CourseTabsNavigation tabs={[]} />);
|
||||
expect(screen.getByRole('button', { name: 'More...' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with tabs', () => {
|
||||
const tabs = [
|
||||
{ url: 'http://test-url1', title: 'Item 1', slug: 'test1' },
|
||||
{ url: 'http://test-url2', title: 'Item 2', slug: 'test2' },
|
||||
];
|
||||
const mockData = {
|
||||
tabs,
|
||||
activeTabSlug: tabs[0].slug,
|
||||
};
|
||||
render(<CourseTabsNavigation {...mockData} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[0].title }))
|
||||
.toHaveAttribute('href', tabs[0].url)
|
||||
.toHaveClass('active');
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[1].title }))
|
||||
.toHaveAttribute('href', tabs[1].url)
|
||||
.not.toHaveClass('active');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import logo from './assets/logo.svg';
|
||||
import AnonymousUserMenu from './AnonymousUserMenu';
|
||||
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
|
||||
import messages from './messages';
|
||||
|
||||
function LinkedLogo({
|
||||
href,
|
||||
@@ -28,40 +28,54 @@ LinkedLogo.propTypes = {
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
courseOrg, courseNumber, courseTitle,
|
||||
function Header({
|
||||
courseOrg, courseNumber, courseTitle, intl,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig(
|
||||
authenticatedUser,
|
||||
getConfig().ENTERPRISE_LEARNER_PORTAL_HOSTNAME,
|
||||
getConfig().LMS_BASE_URL,
|
||||
);
|
||||
|
||||
let headerLogo = (
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={getConfig().LOGO_URL}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
);
|
||||
if (enterpriseCustomerBrandingConfig && Object.keys(enterpriseCustomerBrandingConfig).length > 0) {
|
||||
headerLogo = (
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={enterpriseCustomerBrandingConfig.logoDestination}
|
||||
src={enterpriseCustomerBrandingConfig.logo}
|
||||
alt={enterpriseCustomerBrandingConfig.logoAltText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={logo}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
<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">
|
||||
{headerLogo}
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
</div>
|
||||
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Button>
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
{authenticatedUser.username}
|
||||
</span>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
{authenticatedUser && (
|
||||
<AuthenticatedUserDropdown
|
||||
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
|
||||
username={authenticatedUser.username}
|
||||
/>
|
||||
)}
|
||||
{!authenticatedUser && (
|
||||
<AnonymousUserMenu />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -71,6 +85,7 @@ Header.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
@@ -78,3 +93,5 @@ Header.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Header);
|
||||
|
||||
29
src/course-header/Header.test.jsx
Normal file
29
src/course-header/Header.test.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
authenticatedUser, initializeMockApp, render, screen,
|
||||
} from '../setupTest';
|
||||
import { Header } from './index';
|
||||
|
||||
describe('Header', () => {
|
||||
beforeAll(async () => {
|
||||
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
|
||||
await initializeMockApp();
|
||||
});
|
||||
|
||||
it('displays user button', () => {
|
||||
render(<Header />);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
|
||||
});
|
||||
|
||||
it('displays course data', () => {
|
||||
const courseData = {
|
||||
courseOrg: 'course-org',
|
||||
courseNumber: 'course-number',
|
||||
courseTitle: 'course-title',
|
||||
};
|
||||
render(<Header {...courseData} />);
|
||||
|
||||
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
|
||||
<title>logo</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
|
||||
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
|
||||
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1,11 +1,46 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.navigation.course.tabs.label': {
|
||||
courseMaterial: {
|
||||
id: 'learn.navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
},
|
||||
dashboard: {
|
||||
id: 'header.menu.dashboard.label',
|
||||
defaultMessage: 'Dashboard',
|
||||
description: 'The text for the user menu Dashboard navigation link.',
|
||||
},
|
||||
help: {
|
||||
id: 'header.help.label',
|
||||
defaultMessage: 'Help',
|
||||
description: 'The text for the link to the Help Center',
|
||||
},
|
||||
profile: {
|
||||
id: 'header.menu.profile.label',
|
||||
defaultMessage: 'Profile',
|
||||
description: 'The text for the user menu Profile navigation link.',
|
||||
},
|
||||
account: {
|
||||
id: 'header.menu.account.label',
|
||||
defaultMessage: 'Account',
|
||||
description: 'The text for the user menu Account navigation link.',
|
||||
},
|
||||
orderHistory: {
|
||||
id: 'header.menu.orderHistory.label',
|
||||
defaultMessage: 'Order History',
|
||||
description: 'The text for the user menu Order History navigation link.',
|
||||
},
|
||||
skipNavLink: {
|
||||
id: 'header.navigation.skipNavLink',
|
||||
defaultMessage: 'Skip to main content.',
|
||||
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
|
||||
},
|
||||
signOut: {
|
||||
id: 'header.menu.signOut.label',
|
||||
defaultMessage: 'Sign Out',
|
||||
description: 'The label for the user menu Sign Out action.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function CourseDates({
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h4>Upcoming Dates</h4>
|
||||
<div><strong>Course Start:</strong><br />{start}</div>
|
||||
<div><strong>Course End:</strong><br />{end}</div>
|
||||
<div><strong>Enrollment Start:</strong><br />{enrollmentStart}</div>
|
||||
<div><strong>Enrollment End:</strong><br />{enrollmentEnd}</div>
|
||||
<div><strong>Mode:</strong><br />{enrollmentMode}</div>
|
||||
<div>{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseDates.propTypes = {
|
||||
start: PropTypes.string,
|
||||
end: PropTypes.string,
|
||||
enrollmentStart: PropTypes.string,
|
||||
enrollmentEnd: PropTypes.string,
|
||||
enrollmentMode: PropTypes.string,
|
||||
isEnrolled: PropTypes.bool,
|
||||
};
|
||||
|
||||
CourseDates.defaultProps = {
|
||||
start: null,
|
||||
end: null,
|
||||
enrollmentStart: null,
|
||||
enrollmentEnd: null,
|
||||
enrollmentMode: null,
|
||||
isEnrolled: false,
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { AlertList } from '../user-messages';
|
||||
|
||||
import CourseDates from './CourseDates';
|
||||
import Section from './Section';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
// Note that we import from the component files themselves in the enrollment-alert package.
|
||||
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
|
||||
// default export.
|
||||
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
const { EnrollmentAlert, StaffEnrollmentAlert } = React.lazy(() => import('../enrollment-alert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
|
||||
|
||||
export default function CourseHome() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
const {
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
sectionIds,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{title}</h2>
|
||||
<Button className="btn-primary" type="button">Resume Course</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export default function Section({ id, courseId }) {
|
||||
const section = useModel('sections', id);
|
||||
const { title, sequenceIds } = section;
|
||||
return (
|
||||
<Collapsible.Advanced className="collapsible-card mb-2">
|
||||
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronDown} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<div className="ml-2 flex-grow-1">{title}</div>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
{sequenceIds.map((sequenceId) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export default function SequenceLink({ id, courseId }) {
|
||||
const sequence = useModel('sequences', id);
|
||||
return (
|
||||
<div className="ml-4">
|
||||
<Link to={`/course/${courseId}/${id}`}>{sequence.title}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
.extend(courseMetadataBase)
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
can_load_courseware: false,
|
||||
});
|
||||
223
src/course-home/data/__factories__/datesTabData.factory.js
Normal file
223
src/course-home/data/__factories__/datesTabData.factory.js
Normal file
@@ -0,0 +1,223 @@
|
||||
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 is not realistic (mix of having access and not), but it
|
||||
// is intended to demonstrate many UI results.
|
||||
Factory.define('datesTabData')
|
||||
.attrs({
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
missed_deadlines: false,
|
||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
},
|
||||
course_date_blocks: [
|
||||
{
|
||||
date: '2020-05-01T17:59:41Z',
|
||||
date_type: 'course-start-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Starts',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-04T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Multi Badges Completed',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-05T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Multi Badges Past Due',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-27T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Past Due 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-27T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Past Due 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-28T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Completed/Due 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-28T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Completed/Due 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-29T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Completed 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-29T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Completed 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2020-06-16T17:59:40.942669Z',
|
||||
date_type: 'verified-upgrade-deadline',
|
||||
description: "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Upgrade to Verified Certificate',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Verified 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Verified 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'ORA Verified 2',
|
||||
extra_info: "ORA Dates are set by the instructor, and can't be changed",
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-18T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Verified 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-18T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Verified 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-19T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'One Unreleased 1',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-19T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Unreleased 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Both Unreleased 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Both Unreleased 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2030-08-23T00:00:00Z',
|
||||
date_type: 'course-end-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Ends',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2030-09-01T00:00:00Z',
|
||||
date_type: 'verification-deadline-date',
|
||||
description: 'You must successfully complete verification before this date to qualify for a Verified Certificate.',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Verification Deadline',
|
||||
extra_info: null,
|
||||
},
|
||||
],
|
||||
has_ended: false,
|
||||
learner_is_full_access: true,
|
||||
user_timezone: 'America/New_York',
|
||||
});
|
||||
5
src/course-home/data/__factories__/index.js
Normal file
5
src/course-home/data/__factories__/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
import './progressTabData.factory';
|
||||
import './upgradeNotificationData.factory';
|
||||
69
src/course-home/data/__factories__/outlineTabData.factory.js
Normal file
69
src/course-home/data/__factories__/outlineTabData.factory.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
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('date_blocks', [])
|
||||
.attr('course_blocks', ['courseId'], courseId => {
|
||||
const { courseBlocks } = buildMinimalCourseBlocks(courseId);
|
||||
return {
|
||||
blocks: courseBlocks.blocks,
|
||||
};
|
||||
})
|
||||
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
|
||||
course_date_blocks: dateBlocks,
|
||||
user_timezone: 'UTC',
|
||||
}))
|
||||
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
|
||||
has_visited_course: false,
|
||||
url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
|
||||
}))
|
||||
.attr('verified_mode', ['host'], (host) => ({
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
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,
|
||||
missed_deadlines: false,
|
||||
},
|
||||
enroll_alert: {
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
},
|
||||
handouts_html: '<ul><li>Handout 1</li></ul>',
|
||||
offer: null,
|
||||
welcome_message_html: '<p>Welcome to this course!</p>',
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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',
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 0,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 0.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',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
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,20 @@
|
||||
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('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);
|
||||
634
src/course-home/data/__snapshots__/redux.test.js.snap
Normal file
634
src/course-home/data/__snapshots__/redux.test.js.snap
Normal file
@@ -0,0 +1,634 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Completed",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
},
|
||||
Object {
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"dateType": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"learnerHasAccess": true,
|
||||
"title": "One Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"dateType": "course-end-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
},
|
||||
Object {
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"dateType": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Verification Deadline",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
"hasEnded": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"learnerIsFullAccess": true,
|
||||
"userTimezone": "America/New_York",
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"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",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sections": 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 [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "Title of Section",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
"effortActivities": undefined,
|
||||
"effortTime": undefined,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "Title of Sequence",
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
Object {
|
||||
"analyticsId": "edx.tool.verified_upgrade",
|
||||
"title": "Upgrade to Verified",
|
||||
"url": "https://example.com/upgrade",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
"userTimezone": "UTC",
|
||||
},
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"timeOffsetMillis": 0,
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "ABCD1234",
|
||||
"upgradeUrl": "http://localhost:18000/dashboard",
|
||||
},
|
||||
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
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",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"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",
|
||||
"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,
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 0,
|
||||
"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,
|
||||
"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",
|
||||
},
|
||||
}
|
||||
`;
|
||||
423
src/course-home/data/api.js
Normal file
423
src/course-home/data/api.js
Normal file
@@ -0,0 +1,423 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
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 {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
|
||||
// use within the MFE to differentiate between course home and courseware.
|
||||
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
Object.values(blocks).forEach(block => {
|
||||
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,
|
||||
sequenceIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sequential':
|
||||
models.sequences[block.id] = {
|
||||
complete: block.complete,
|
||||
description: block.description,
|
||||
due: block.due,
|
||||
effortActivities: block.effort_activities,
|
||||
effortTime: block.effort_time,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
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,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Next go through each list and use their child lists to decorate those children with a
|
||||
// reference back to their parent.
|
||||
Object.values(models.courses).forEach(course => {
|
||||
if (Array.isArray(course.sectionIds)) {
|
||||
course.sectionIds.forEach(sectionId => {
|
||||
const section = models.sections[sectionId];
|
||||
section.courseId = course.id;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(models.sections).forEach(section => {
|
||||
if (Array.isArray(section.sequenceIds)) {
|
||||
section.sequenceIds.forEach(sequenceId => {
|
||||
if (sequenceId in models.sequences) {
|
||||
models.sequences[sequenceId].sectionId = section.id;
|
||||
} else {
|
||||
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
function processTabDataErrorRedirect(error) {
|
||||
const { httpErrorResponseData, httpErrorStatus } = error && error.customAttributes;
|
||||
|
||||
if (httpErrorStatus === 403) {
|
||||
// Currently, only 403 errors contain redirect content
|
||||
try {
|
||||
const { redirect } = JSON.parse(httpErrorResponseData);
|
||||
if (redirect) {
|
||||
global.location.replace(redirect);
|
||||
return true;
|
||||
}
|
||||
} catch (exc) {
|
||||
// ignore any json parse errors, might be an actual 403 without redirect json content
|
||||
}
|
||||
}
|
||||
|
||||
// Did not do any redirecting
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
// Just uncomment the next few lines and the immediate 'return' in the function below
|
||||
// import { Factory } from 'rosie';
|
||||
// import './__factories__';
|
||||
export async function getDatesTabData(courseId) {
|
||||
// return camelCaseObject(Factory.build('datesTabData'));
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return null;
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
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;
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return null;
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProctoringInfoData(courseId, username) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
||||
if (username) {
|
||||
url += `&username=${encodeURIComponent(username)}`;
|
||||
}
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
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 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 verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
certData,
|
||||
courseBlocks,
|
||||
courseGoals,
|
||||
courseTools,
|
||||
datesBannerInfo,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
handoutsHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
offer,
|
||||
resumeCourse,
|
||||
timeOffsetMillis, // This should move to a global time correction reference
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postCourseDeadlines(courseId, model) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
||||
return getAuthenticatedHttpClient().post(url.href, {
|
||||
course_key: courseId,
|
||||
research_event_data: { location: `${model}-tab` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function postCourseGoals(courseId, goalKey) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
|
||||
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
|
||||
}
|
||||
|
||||
export async function postDismissWelcomeMessage(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
||||
}
|
||||
|
||||
export async function postRequestCert(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
|
||||
await getAuthenticatedHttpClient().post(url.href);
|
||||
}
|
||||
|
||||
export async function executePostFromPostEvent(postData, researchEventData) {
|
||||
const url = new URL(postData.url);
|
||||
return getAuthenticatedHttpClient().post(url.href, {
|
||||
course_key: postData.bodyParams.courseId,
|
||||
research_event_data: researchEventData,
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
9
src/course-home/data/index.js
Normal file
9
src/course-home/data/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
fetchDatesTab,
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
saveCourseGoal,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
176
src/course-home/data/redux.test.js
Normal file
176
src/course-home/data/redux.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Factory } from 'rosie';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const { id: courseId } = courseHomeMetadata;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.reset();
|
||||
loggingService.logError.mockReset();
|
||||
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
describe('Test fetchDatesTab', () => {
|
||||
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
|
||||
|
||||
it('Should fail to fetch if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
|
||||
const datesUrl = `${datesBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchOutlineTab', () => {
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
||||
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
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`;
|
||||
axiosMock.onPost(goalUrl).reply(200, {});
|
||||
|
||||
await thunks.saveCourseGoal(courseId, 'unsure');
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test resetDeadlines', () => {
|
||||
it('Should reset course deadlines', async () => {
|
||||
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
|
||||
const model = 'dates';
|
||||
axiosMock.onPost(resetUrl).reply(201, {});
|
||||
|
||||
const getTabDataMock = jest.fn(() => ({
|
||||
type: 'MOCK_ACTION',
|
||||
}));
|
||||
|
||||
await executeThunk(thunks.resetDeadlines(courseId, model, getTabDataMock), store.dispatch);
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}","research_event_data":{"location":"dates-tab"}}`);
|
||||
|
||||
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test dismissWelcomeMessage', () => {
|
||||
it('Should dismiss welcome message', async () => {
|
||||
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
|
||||
axiosMock.onPost(dismissUrl).reply(201);
|
||||
|
||||
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/course-home/data/slice.js
Normal file
58
src/course-home/data/slice.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'course-home',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
gradesFeatureIsLocked: false,
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
},
|
||||
reducers: {
|
||||
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,
|
||||
link,
|
||||
linkText,
|
||||
} = payload;
|
||||
state.toastBodyLink = link;
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
setGradesFeatureStatus: (state, { payload }) => {
|
||||
state.gradesFeatureIsLocked = payload.gradesFeatureIsLocked;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
setCallToActionToast,
|
||||
setGradesFeatureStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
136
src/course-home/data/thunks.js
Normal file
136
src/course-home/data/thunks.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
executePostFromPostEvent,
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
postCourseGoals,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
addModel,
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
} from './slice';
|
||||
|
||||
const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(courseHomeCourseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedTabData && tabDataResult.value === null) {
|
||||
// null tab data indicates that we have redirected elsewhere - just exit and don't visibly stop loading
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchedTabData) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDatesTab(courseId) {
|
||||
return fetchTab(courseId, 'dates', getDatesTabData);
|
||||
}
|
||||
|
||||
export function fetchProgressTab(courseId, targetUserId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
|
||||
}
|
||||
|
||||
export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
|
||||
export function requestCert(courseId) {
|
||||
return async () => postRequestCert(courseId);
|
||||
}
|
||||
|
||||
export function resetDeadlines(courseId, model, getTabData) {
|
||||
return async (dispatch) => {
|
||||
postCourseDeadlines(courseId, model).then(response => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
link_text: linkText,
|
||||
} = data;
|
||||
dispatch(getTabData(courseId));
|
||||
dispatch(setCallToActionToast({ header, link, linkText }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveCourseGoal(courseId, goalKey) {
|
||||
return postCourseGoals(courseId, goalKey);
|
||||
}
|
||||
|
||||
export function processEvent(eventData, getTabData) {
|
||||
return async (dispatch) => {
|
||||
// Pulling this out early so the data doesn't get camelCased and is easier
|
||||
// to use when it's passed to the backend
|
||||
const { research_event_data: researchEventData } = eventData;
|
||||
const event = camelCaseObject(eventData);
|
||||
if (event.eventName === eventTypes.POST_EVENT) {
|
||||
executePostFromPostEvent(event.postData, researchEventData).then(response => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
link_text: linkText,
|
||||
} = data;
|
||||
dispatch(getTabData(event.postData.bodyParams.courseId));
|
||||
dispatch(setCallToActionToast({ header, link, linkText }));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
72
src/course-home/dates-tab/DatesTab.jsx
Normal file
72
src/course-home/dates-tab/DatesTab.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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/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,
|
||||
} = 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 */ }
|
||||
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||
<SuggestedScheduleHeader />
|
||||
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} />
|
||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||
</>
|
||||
)}
|
||||
<Timeline mmp2p={mmp2p} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DatesTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesTab);
|
||||
298
src/course-home/dates-tab/DatesTab.test.jsx
Normal file
298
src/course-home/dates-tab/DatesTab.test.jsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import DatesTab from './DatesTab';
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { fireEvent, initializeMockApp, waitFor } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { TabContainer } from '../../tab-page';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('DatesTab', () => {
|
||||
let axiosMock;
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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
|
||||
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
|
||||
// better than assuming anything about how the surrounding elements are organized by div and span or whatever. And
|
||||
// better than adding non-style class names.
|
||||
// Hence the following getDay query helper.
|
||||
async function getDay(date) {
|
||||
const dateNode = await screen.findByText(date);
|
||||
let parent = dateNode.parentElement;
|
||||
while (parent) {
|
||||
if (parent.dataset && parent.dataset.testid === 'dates-day') {
|
||||
return {
|
||||
day: parent,
|
||||
header: within(parent).getByTestId('dates-header'),
|
||||
items: within(parent).queryAllByTestId('dates-item'),
|
||||
};
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
throw new Error('Did not find day container');
|
||||
}
|
||||
|
||||
describe('when receiving a full set of dates data', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
|
||||
it('handles unreleased & complete', async () => {
|
||||
const { header } = await getDay('Sun, May 3, 2020');
|
||||
const badges = within(header).getAllByTestId('dates-badge');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('Completed');
|
||||
expect(badges[1]).toHaveTextContent('Not yet released');
|
||||
});
|
||||
|
||||
it('handles unreleased & past due', async () => {
|
||||
const { header } = await getDay('Mon, May 4, 2020');
|
||||
const badges = within(header).getAllByTestId('dates-badge');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('Past due');
|
||||
expect(badges[1]).toHaveTextContent('Not yet released');
|
||||
});
|
||||
|
||||
it('handles verified only', async () => {
|
||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||
const badge = within(day).getByTestId('dates-badge');
|
||||
expect(badge).toHaveTextContent('Verified only');
|
||||
});
|
||||
|
||||
it('verified only has no link', async () => {
|
||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||
expect(within(day).queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('same status items have header badge', async () => {
|
||||
const { day, header } = await getDay('Tue, May 26, 2020');
|
||||
const badge = within(header).getByTestId('dates-badge');
|
||||
expect(badge).toHaveTextContent('Past due'); // one header badge
|
||||
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
|
||||
});
|
||||
|
||||
it('different status items have individual badges', async () => {
|
||||
const { header, items } = await getDay('Thu, May 28, 2020');
|
||||
const headerBadges = within(header).queryAllByTestId('dates-badge');
|
||||
expect(headerBadges).toHaveLength(0); // no header badges
|
||||
expect(items).toHaveLength(2);
|
||||
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
|
||||
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past due');
|
||||
});
|
||||
|
||||
it('shows extra info', async () => {
|
||||
const { items } = await getDay('Sat, Aug 17, 2030');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
const tipIcon = within(items[2]).getByTestId('dates-extra-info');
|
||||
const tipText = "ORA Dates are set by the instructor, and can't be changed";
|
||||
|
||||
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suggested schedule messaging', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
setMetadata({ is_self_paced: true, is_enrolled: true });
|
||||
history.push(`/course/${courseId}/dates`);
|
||||
});
|
||||
|
||||
it('renders SuggestedScheduleHeader', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: false,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
};
|
||||
|
||||
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. But don’t worry—it’s flexible so you can learn at your own pace.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders UpgradeToCompleteAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
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 UpgradeToShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: true,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
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());
|
||||
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 ShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
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());
|
||||
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: 'Shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles shift due dates click', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
// confirm "Shift due dates" button has rendered
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument());
|
||||
|
||||
// update response to reflect shifted dates
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
const resetDeadlinesData = {
|
||||
header: "You've successfully shifted your dates!",
|
||||
};
|
||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`).reply(200, resetDeadlinesData);
|
||||
|
||||
// click "Shift due dates"
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Shift due dates' }));
|
||||
|
||||
// wait for page to reload & Toast to render
|
||||
await waitFor(() => expect(screen.getByText("You've successfully shifted your dates!")).toBeInTheDocument());
|
||||
// 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/course-home/dates-tab/index.jsx
Normal file
3
src/course-home/dates-tab/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import DatesTab from './DatesTab';
|
||||
|
||||
export default DatesTab;
|
||||
34
src/course-home/dates-tab/messages.js
Normal file
34
src/course-home/dates-tab/messages.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
completed: {
|
||||
id: 'learning.dates.badge.completed',
|
||||
defaultMessage: 'Completed',
|
||||
},
|
||||
dueNext: {
|
||||
id: 'learning.dates.badge.dueNext',
|
||||
defaultMessage: 'Due next',
|
||||
},
|
||||
pastDue: {
|
||||
id: 'learning.dates.badge.pastDue',
|
||||
defaultMessage: 'Past due',
|
||||
},
|
||||
title: {
|
||||
id: 'learning.dates.title',
|
||||
defaultMessage: 'Important dates',
|
||||
},
|
||||
today: {
|
||||
id: 'learning.dates.badge.today',
|
||||
defaultMessage: 'Today',
|
||||
},
|
||||
unreleased: {
|
||||
id: 'learning.dates.badge.unreleased',
|
||||
defaultMessage: 'Not yet released',
|
||||
},
|
||||
verifiedOnly: {
|
||||
id: 'learning.dates.badge.verifiedOnly',
|
||||
defaultMessage: 'Verified only',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
162
src/course-home/dates-tab/timeline/Day.jsx
Normal file
162
src/course-home/dates-tab/timeline/Day.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
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 { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from '../utils';
|
||||
|
||||
function Day({
|
||||
date,
|
||||
first,
|
||||
intl,
|
||||
items,
|
||||
last,
|
||||
/** [MM-P2P] Example */
|
||||
mmp2p,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('dates', courseId);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const mmp2pOverride = (
|
||||
mmp2p.state.isEnabled
|
||||
&& items.some((item) => item.dateType === 'verified-upgrade-deadline')
|
||||
);
|
||||
return (
|
||||
<li className="dates-day pb-4" data-testid="dates-day">
|
||||
{/* Top Line */}
|
||||
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
|
||||
|
||||
{/* Dot */}
|
||||
<div className={classNames(color, 'dates-dot border border-gray-900')} />
|
||||
|
||||
{/* Bottom Line */}
|
||||
{!last && <div className="dates-line-bottom border-1 border-left border-gray-900 bg-gray-900" />}
|
||||
|
||||
{/* Content */}
|
||||
<div className="d-inline-block ml-3 pl-2">
|
||||
<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) => {
|
||||
/** [MM-P2P] Experiment (conditional) */
|
||||
const { badges: itemBadges } = mmp2pOverride
|
||||
? 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-primary-700' : 'text-gray-500';
|
||||
|
||||
return (
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
|
||||
<div>
|
||||
<span className="small">
|
||||
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
|
||||
{showDueDateTime && (
|
||||
<span>
|
||||
<span className="mx-1">due</span>
|
||||
<FormattedTime
|
||||
value={date}
|
||||
timeZoneName="short"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{itemBadges}
|
||||
{item.extraInfo && (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Tooltip>{item.extraInfo}</Tooltip>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" data-testid="dates-extra-info" />
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{ mmp2pOverride
|
||||
? (
|
||||
<div className="small mb-2">
|
||||
You are still eligible to upgrade to a Verified Certificate!
|
||||
Unlock full course access and highlight the knowledge you'll gain.
|
||||
</div>
|
||||
)
|
||||
: (item.description && <div className="small mb-2">{item.description}</div>)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Day.propTypes = {
|
||||
date: PropTypes.objectOf(Date).isRequired,
|
||||
first: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
dateType: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
dueNext: PropTypes.bool,
|
||||
learnerHasAccess: PropTypes.bool,
|
||||
link: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
})).isRequired,
|
||||
last: PropTypes.bool,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
isEnabled: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
Day.defaultProps = {
|
||||
first: false,
|
||||
last: false,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
state: {
|
||||
isEnabled: false,
|
||||
upgradeDeadline: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default injectIntl(Day);
|
||||
47
src/course-home/dates-tab/timeline/Day.scss
Normal file
47
src/course-home/dates-tab/timeline/Day.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
$dot-radius: 0.3rem;
|
||||
$dot-size: $dot-radius * 2;
|
||||
$offset: $dot-radius * 1.5;
|
||||
|
||||
.dates-day {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dates-line-top {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: $offset;
|
||||
top: 0;
|
||||
height: $offset;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dates-dot {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
left: $dot-radius * 0.5; // save room for today's larger size
|
||||
top: $offset;
|
||||
height: $dot-size;
|
||||
width: $dot-size;
|
||||
z-index: 1;
|
||||
|
||||
&.dates-bg-today {
|
||||
left: 0;
|
||||
top: $offset - $dot-radius;
|
||||
height: $dot-size * 1.5;
|
||||
width: $dot-size * 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.dates-line-bottom {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: $offset + $dot-size;
|
||||
bottom: 0;
|
||||
left: $offset;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dates-bg-today {
|
||||
background: #ffdb87;
|
||||
}
|
||||
82
src/course-home/dates-tab/timeline/Timeline.jsx
Normal file
82
src/course-home/dates-tab/timeline/Timeline.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
/** [MM-P2P] Experiment (import) */
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import Day from './Day';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
/** [MM-P2P] Experiment (argument) */
|
||||
export default function Timeline({ mmp2p }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
// Group date items by day (assuming they are sorted in first place) and add some metadata
|
||||
const groupedDates = [];
|
||||
const now = new Date();
|
||||
let foundNextDue = false;
|
||||
let foundToday = false;
|
||||
courseDateBlocks.forEach(courseDateBlock => {
|
||||
const dateInfo = { ...courseDateBlock };
|
||||
const parsedDate = new Date(dateInfo.date);
|
||||
|
||||
if (!foundNextDue && parsedDate >= now && isLearnerAssignment(dateInfo) && !dateInfo.complete) {
|
||||
foundNextDue = true;
|
||||
dateInfo.dueNext = true;
|
||||
}
|
||||
|
||||
if (!foundToday) {
|
||||
const compared = daycmp(parsedDate, now);
|
||||
if (compared === 0) {
|
||||
foundToday = true;
|
||||
} else if (compared > 0) {
|
||||
foundToday = true;
|
||||
groupedDates.push({
|
||||
date: now,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (groupedDates.length === 0 || daycmp(groupedDates[groupedDates.length - 1].date, parsedDate) !== 0) {
|
||||
// Add new grouped date
|
||||
groupedDates.push({
|
||||
date: parsedDate,
|
||||
items: [dateInfo],
|
||||
first: groupedDates.length === 0,
|
||||
});
|
||||
} else {
|
||||
groupedDates[groupedDates.length - 1].items.push(dateInfo);
|
||||
}
|
||||
});
|
||||
if (!foundToday) {
|
||||
groupedDates.push({ date: now, items: [] });
|
||||
}
|
||||
if (groupedDates.length) {
|
||||
groupedDates[groupedDates.length - 1].last = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
Timeline.propTypes = {
|
||||
mmp2p: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
Timeline.defaultProps = {
|
||||
mmp2p: {},
|
||||
};
|
||||
118
src/course-home/dates-tab/timeline/badgelist.jsx
Normal file
118
src/course-home/dates-tab/timeline/badgelist.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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 messages from '../messages';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
function hasAccess(item) {
|
||||
return item.learnerHasAccess;
|
||||
}
|
||||
|
||||
function isComplete(assignment) {
|
||||
return assignment.complete;
|
||||
}
|
||||
|
||||
function isPastDue(assignment) {
|
||||
return !isComplete(assignment) && (new Date(assignment.date) < new Date());
|
||||
}
|
||||
|
||||
function isUnreleased(assignment) {
|
||||
return !assignment.link;
|
||||
}
|
||||
|
||||
// Pass a null item if you want to get a whole day's badge list, not just one item's list.
|
||||
// Returns an object with 'color' and 'badges' properties.
|
||||
function getBadgeListAndColor(date, intl, item, items) {
|
||||
const now = new Date();
|
||||
const assignments = items.filter(isLearnerAssignment);
|
||||
const isToday = daycmp(date, now) === 0;
|
||||
const isInFuture = daycmp(date, now) > 0;
|
||||
|
||||
// This badge info list is in order of priority (they will appear left to right in this order and the first badge
|
||||
// sets the color of the dot in the timeline).
|
||||
const badgesInfo = [
|
||||
{
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'bg-warning-300',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-light-500',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||
bg: 'bg-dark-200',
|
||||
className: 'text-white',
|
||||
},
|
||||
{
|
||||
message: messages.dueNext,
|
||||
shownForDay: !isToday && assignments.some(x => x.dueNext),
|
||||
shownForItem: x => x.dueNext,
|
||||
bg: 'bg-gray-500',
|
||||
className: 'text-white',
|
||||
},
|
||||
{
|
||||
message: messages.unreleased,
|
||||
shownForDay: assignments.length && assignments.every(isUnreleased),
|
||||
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
|
||||
className: 'border border-gray-500 text-gray-500',
|
||||
},
|
||||
{
|
||||
message: messages.verifiedOnly,
|
||||
shownForDay: items.length && items.every(x => !hasAccess(x)),
|
||||
shownForItem: x => !hasAccess(x),
|
||||
icon: faLock,
|
||||
bg: 'bg-dark-700',
|
||||
className: 'text-white',
|
||||
},
|
||||
];
|
||||
let color = null; // first color of any badge
|
||||
const badges = (
|
||||
<>
|
||||
{badgesInfo.map(b => {
|
||||
let shown = b.shownForDay;
|
||||
if (item) {
|
||||
if (b.shownForDay) {
|
||||
shown = false; // don't double up, if the day already has this badge
|
||||
} else {
|
||||
shown = b.shownForItem && b.shownForItem(item);
|
||||
}
|
||||
}
|
||||
if (!shown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!color && !isInFuture) {
|
||||
color = b.bg;
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
if (!color && isInFuture) {
|
||||
color = 'bg-gray-900';
|
||||
}
|
||||
|
||||
return {
|
||||
color,
|
||||
badges,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { getBadgeListAndColor };
|
||||
16
src/course-home/dates-tab/utils.jsx
Normal file
16
src/course-home/dates-tab/utils.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
function daycmp(a, b) {
|
||||
if (a.getFullYear() < b.getFullYear()) { return -1; }
|
||||
if (a.getFullYear() > b.getFullYear()) { return 1; }
|
||||
if (a.getMonth() < b.getMonth()) { return -1; }
|
||||
if (a.getMonth() > b.getMonth()) { return 1; }
|
||||
if (a.getDate() < b.getDate()) { return -1; }
|
||||
if (a.getDate() > b.getDate()) { return 1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
// item is a date block returned from the API
|
||||
function isLearnerAssignment(item) {
|
||||
return item.learnerHasAccess && item.dateType === 'assignment-due-date';
|
||||
}
|
||||
|
||||
export { daycmp, isLearnerAssignment };
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './CourseHome';
|
||||
130
src/course-home/outline-tab/DateSummary.jsx
Normal file
130
src/course-home/outline-tab/DateSummary.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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';
|
||||
|
||||
export default function DateSummary({
|
||||
dateBlock,
|
||||
userTimezone,
|
||||
/** [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">
|
||||
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
||||
<div className="ml-1 font-weight-bold">
|
||||
<FormattedDate
|
||||
/** [MM-P2P] Experiment */
|
||||
value={showMMP2P ? mmp2p.state.upgradeDeadline : dateBlock.date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/** [MM-P2P] Experiment (conditional) */}
|
||||
{ showMMP2P ? (
|
||||
<div className="row ml-4 pr-2">
|
||||
<div className="date-summary-text">
|
||||
<div className="font-weight-bold mt-2">
|
||||
Last chance to upgrade
|
||||
</div>
|
||||
</div>
|
||||
<div className="date-summary-text mt-1">
|
||||
You are still eligible to upgrade to a Verified Certificate!
|
||||
Unlock full course access and highlight the knowledge you'll gain.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
DateSummary.propTypes = {
|
||||
dateBlock: PropTypes.shape({
|
||||
date: PropTypes.string.isRequired,
|
||||
dateType: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
link: PropTypes.string,
|
||||
linkText: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
learnerHasAccess: PropTypes.bool,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
isEnabled: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
DateSummary.defaultProps = {
|
||||
userTimezone: null,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
state: {
|
||||
isEnabled: false,
|
||||
upgradeDeadline: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
8
src/course-home/outline-tab/DateSummary.scss
Normal file
8
src/course-home/outline-tab/DateSummary.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.date-summary-text {
|
||||
margin-left: 2px;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.description-link {
|
||||
margin-left: 1px;
|
||||
}
|
||||
11
src/course-home/outline-tab/LmsHtmlFragment.css
Normal file
11
src/course-home/outline-tab/LmsHtmlFragment.css
Normal file
@@ -0,0 +1,11 @@
|
||||
body a {
|
||||
color: #00688D;
|
||||
}
|
||||
|
||||
body.inline-link a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.small {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
50
src/course-home/outline-tab/LmsHtmlFragment.jsx
Normal file
50
src/course-home/outline-tab/LmsHtmlFragment.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export default function LmsHtmlFragment({
|
||||
className,
|
||||
html,
|
||||
title,
|
||||
...rest
|
||||
}) {
|
||||
const wholePage = `
|
||||
<html>
|
||||
<head>
|
||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
|
||||
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
|
||||
</head>
|
||||
<body class="${className}">${html}</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const iframe = useRef(null);
|
||||
function handleLoad() {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-100 border-0"
|
||||
onLoad={handleLoad}
|
||||
ref={iframe}
|
||||
referrerPolicy="origin"
|
||||
scrolling="no"
|
||||
srcDoc={wholePage}
|
||||
title={title}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
LmsHtmlFragment.defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
LmsHtmlFragment.propTypes = {
|
||||
className: PropTypes.string,
|
||||
html: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
253
src/course-home/outline-tab/OutlineTab.jsx
Normal file
253
src/course-home/outline-tab/OutlineTab.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Toast } from '@edx/paragon';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
import CourseGoalCard from './widgets/CourseGoalCard';
|
||||
import CourseHandouts from './widgets/CourseHandouts';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
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 UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
import useCourseStartAlert from './alerts/course-start-alert';
|
||||
import usePrivateCourseAlert from './alerts/private-course-alert';
|
||||
import useScheduledContentAlert from './alerts/scheduled-content-alert';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
||||
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
|
||||
|
||||
function OutlineTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
},
|
||||
courseGoals: {
|
||||
goalOptions,
|
||||
selectedGoal,
|
||||
} = {},
|
||||
datesBannerInfo,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
|
||||
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', {
|
||||
...eventProperties,
|
||||
event_type: hasVisitedCourse ? 'resume' : 'start',
|
||||
url: resumeCourseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
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 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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast
|
||||
closeLabel={intl.formatMessage(genericMessages.close)}
|
||||
onClose={() => setGoalToastHeader('')}
|
||||
show={!!(goalToastHeader)}
|
||||
>
|
||||
{goalToastHeader}
|
||||
</Toast>
|
||||
<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 variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
|
||||
<div className="row course-outline-tab">
|
||||
<AccountActivationAlert />
|
||||
<div className="col-12">
|
||||
<AlertList
|
||||
topic="outline-private-alerts"
|
||||
customAlerts={{
|
||||
...privateCourseAlert,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col col-12 col-md-8">
|
||||
{ /** [MM-P2P] Experiment (the conditional) */ }
|
||||
{ !MMP2P.state.isEnabled
|
||||
&& (
|
||||
<AlertList
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlertMasquerade,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
|
||||
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
|
||||
</>
|
||||
)}
|
||||
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<CourseGoalCard
|
||||
courseId={courseId}
|
||||
goalOptions={goalOptions}
|
||||
title={title}
|
||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||
/>
|
||||
)}
|
||||
<WelcomeMessage courseId={courseId} />
|
||||
{rootCourseId && (
|
||||
<>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
|
||||
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="list-unstyled">
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
defaultOpen={sections[sectionId].resumeBlock}
|
||||
expand={expandAll}
|
||||
section={sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{rootCourseId && (
|
||||
<div className="col col-12 col-md-4">
|
||||
<ProctoringInfoPanel
|
||||
courseId={courseId}
|
||||
username={username}
|
||||
/>
|
||||
{courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<UpdateGoalSelector
|
||||
courseId={courseId}
|
||||
goalOptions={goalOptions}
|
||||
selectedGoal={courseGoalToDisplay}
|
||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||
/>
|
||||
)}
|
||||
<CourseTools
|
||||
courseId={courseId}
|
||||
/>
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{ MMP2P.state.isEnabled
|
||||
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||
: (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
userTimezone={userTimezone}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
shouldDisplayBorder
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
courseId={courseId}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
<CourseHandouts
|
||||
courseId={courseId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OutlineTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OutlineTab);
|
||||
1225
src/course-home/outline-tab/OutlineTab.test.jsx
Normal file
1225
src/course-home/outline-tab/OutlineTab.test.jsx
Normal file
@@ -0,0 +1,1225 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
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 { 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();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Outline 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 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 store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('outlineTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
function setTabData(attributes, options) {
|
||||
const outlineTabData = Factory.build('outlineTabData', attributes, options);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(<OutlineTab />, { store }));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
// Set defaults for network requests
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onPost(enrollmentUrl).reply(200, {});
|
||||
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
|
||||
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'created',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
});
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
describe('Course Outline', () => {
|
||||
it('displays link to start course', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays link to resume course', async () => {
|
||||
setTabData({
|
||||
resume_course: {
|
||||
has_visited_course: true,
|
||||
url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Resume course' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands section that contains resume block', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
|
||||
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('handles expand/collapse all button click', async () => {
|
||||
await fetchAndRender();
|
||||
// Button renders as "Expand All"
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand all' });
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
|
||||
// Section initially renders collapsed
|
||||
const collapsedSectionNode = screen.getByRole('button', { name: /section/ });
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { complete: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct icon for incomplete assignment', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { complete: false });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to legacy courseware', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: false,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to courseware MFE', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: true,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/course/${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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome Message', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
});
|
||||
|
||||
it('does not render show more/less button under 100 words', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('over 100 words', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
welcome_message_html: '<p>'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ '</p>',
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('shortens message', async () => {
|
||||
expect(screen.getByTestId('short-welcome-message-iframe')).toBeInTheDocument();
|
||||
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders show more/less button and handles click', async () => {
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showMoreButton);
|
||||
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showLessButton);
|
||||
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).not.toBeInTheDocument();
|
||||
showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display if no update available', async () => {
|
||||
setTabData({ welcome_message_html: null });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
['complete', 'Complete the course'],
|
||||
['explore', 'Explore the course'],
|
||||
['unsure', 'Not sure yet'],
|
||||
];
|
||||
|
||||
it('does not render goal widgets if no goals available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('goal is not set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
goal_options: goalOptions,
|
||||
selected_goal: null,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('renders goal card', () => {
|
||||
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders goal selector on goal selection', async () => {
|
||||
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
|
||||
fireEvent.click(certifyGoalButton);
|
||||
|
||||
const goalSelector = await screen.findByTestId('edit-goal-selector');
|
||||
expect(goalSelector).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('goal is set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
goal_options: goalOptions,
|
||||
selected_goal: { text: 'Earn a certificate', key: 'certify' },
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('renders edit goal selector', () => {
|
||||
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates goal on click', async () => {
|
||||
// Open dropdown
|
||||
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
|
||||
await waitFor(() => {
|
||||
expect(dropdownButtonNode).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(dropdownButtonNode);
|
||||
|
||||
// Select a new goal
|
||||
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
|
||||
await waitFor(() => {
|
||||
expect(unsureButtonNode).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(unsureButtonNode);
|
||||
|
||||
// Verify the request was made
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Handouts', () => {
|
||||
it('renders title when handouts are available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display title if no handouts available', async () => {
|
||||
setTabData({ handouts_html: null });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('to access the full course')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display enrollment button if enrollment is not available', async () => {
|
||||
setTabData({
|
||||
enroll_alert: {
|
||||
can_enroll: false,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const alert = await screen.findByText('Welcome to Demonstration Course');
|
||||
expect(alert.parentElement).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();
|
||||
});
|
||||
|
||||
it('displays alert for unenrolled user', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const alert = await screen.findByText('Welcome to Demonstration Course');
|
||||
expect(alert.parentElement).toHaveAttribute('role', 'alert');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles button click', async () => {
|
||||
const { location } = window;
|
||||
delete window.location;
|
||||
window.location = {
|
||||
reload: jest.fn(),
|
||||
};
|
||||
await fetchAndRender();
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Enroll now' });
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
expect(axiosMock.history.post[0].data)
|
||||
.toEqual(JSON.stringify({ course_details: { course_id: courseId } }));
|
||||
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.location = location;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access Expiration Alert', () => {
|
||||
it('has special masquerade text', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2020-01-01T12:00:00Z',
|
||||
masquerading_expired_course: true,
|
||||
upgrade_deadline: null,
|
||||
upgrade_url: null,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not have special masquerade text', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2020-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: null,
|
||||
upgrade_url: null,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Start Alert', () => {
|
||||
// Only appears if enrolled and before start of course
|
||||
it('appears several days out', async () => {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() + 100);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-start-date',
|
||||
date: startDate.toISOString(),
|
||||
title: 'Start',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('Course starts', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
|
||||
});
|
||||
|
||||
it('appears today', 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();
|
||||
const node = await screen.findByText('Course starts', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course End Alert', () => {
|
||||
// Only appears if enrolled and within 14 days before the end of course
|
||||
it('appears several days out', async () => {
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + 13);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: endDate.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('This course is ending', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
|
||||
});
|
||||
|
||||
it('appears today', async () => {
|
||||
const endDate = new Date();
|
||||
endDate.setHours(endDate.getHours() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: endDate.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('This course is ending', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate Available Alert', () => {
|
||||
// Must satisfy two conditions for alert to appear: enrolled and between course end and cert availability
|
||||
it('appears', 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.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(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
});
|
||||
it('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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proctoring Info Panel', () => {
|
||||
const onboardingReleaseDate = new Date();
|
||||
onboardingReleaseDate.setDate(new Date().getDate() - 7);
|
||||
it('appears', async () => {
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for verified', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
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 can take 2+ business days.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for rejected', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'rejected',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
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 can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for submitted', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'submitted',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
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 can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for second_review_required', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'second_review_required',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
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 can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears for other_course_approved if not expiring soon', async () => {
|
||||
const expirationDate = new Date();
|
||||
// Set the expiration date 40 days in the future, so as not to trigger the 28 day expiration warning
|
||||
expirationDate.setTime(expirationDate.getTime() + 3456900000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'other_course_approved',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
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 () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'other_course_approved',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
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 () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: '',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
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 can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not appear for 404', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(404);
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears with a disabled link if onboarding not yet released', async () => {
|
||||
const futureReleaseDate = new Date();
|
||||
futureReleaseDate.setDate(new Date().getDate() + 7);
|
||||
const expectedDateStr = new Intl.DateTimeFormat('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(futureReleaseDate);
|
||||
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: '',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: futureReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText(`Onboarding Opens: ${expectedDateStr}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears and ignores a missing release date', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: null,
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
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 can take 2+ business days.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upgrade Card', () => {
|
||||
it('renders title when upgrade is available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays link to upgrade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('viewing upgrade card sends analytics', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
sendTrackingLogEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
creative: 'sidebarupsell',
|
||||
name: 'In-Course Verification Prompt',
|
||||
position: 'sidebar-message',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
|
||||
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
creative: 'sidebarupsell',
|
||||
name: 'In-Course Verification Prompt',
|
||||
position: 'sidebar-message',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: 'course_home_green',
|
||||
linkType: 'button',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/course-home/outline-tab/Section.jsx
Normal file
124
src/course-home/outline-tab/Section.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, IconButton } from '@edx/paragon';
|
||||
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
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';
|
||||
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
|
||||
function Section({
|
||||
courseId,
|
||||
defaultOpen,
|
||||
expand,
|
||||
intl,
|
||||
section,
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(expand);
|
||||
}, [expand]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
}, []);
|
||||
|
||||
const sectionTitle = (
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left mt-1 text-success"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedSection)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left mt-1 text-gray-400"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteSection)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 ml-3 p-0 font-weight-bold text-dark-500">
|
||||
<span className="align-middle">{title}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={section} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible
|
||||
className="mb-2"
|
||||
styling="card-lg"
|
||||
title={sectionTitle}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open); }}
|
||||
iconWhenClosed={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
icon={faPlus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
<IconButton
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
defaultOpen: PropTypes.bool.isRequired,
|
||||
expand: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
section: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Section);
|
||||
123
src/course-home/outline-tab/SequenceLink.jsx
Normal file
123
src/course-home/outline-tab/SequenceLink.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import EffortEstimate from '../../shared/effort-estimate';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function SequenceLink({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
legacyWebUrl,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
datesWidget: {
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
const {
|
||||
canLoadCourseware,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
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={legacyWebUrl}>{title}</Hyperlink>
|
||||
);
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedAssignment)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-3 text-break">
|
||||
<span className="align-middle">{displayTitle}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
{due && (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
first: PropTypes.bool.isRequired,
|
||||
sequence: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceLink);
|
||||
@@ -0,0 +1,190 @@
|
||||
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,
|
||||
} = 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;
|
||||
};
|
||||
|
||||
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:
|
||||
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,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateStatusAlert);
|
||||
@@ -0,0 +1,79 @@
|
||||
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 {
|
||||
isEnrolled,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
certData,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isWebCert = downloadUrl === null;
|
||||
|
||||
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.
|
||||
const isVisible = isEnrolled && hasAlertingCertStatus;
|
||||
const payload = {
|
||||
certificateAvailableDate,
|
||||
certURL,
|
||||
certStatus,
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCertificateStatusAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCertificateStatusAlert: CertificateStatusAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCertificateStatusAlert;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
@@ -0,0 +1,16 @@
|
||||
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.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
function CourseEndAlert({ payload }) {
|
||||
const {
|
||||
description,
|
||||
endDate,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
let msg;
|
||||
const delta = new Date(endDate) - new Date();
|
||||
if (delta < DAY_MS) {
|
||||
const courseEndTime = (
|
||||
<FormattedTime
|
||||
key="courseEndTime"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
msg = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.short"
|
||||
defaultMessage="This course is ending {timeRemaining} at {courseEndTime}."
|
||||
description="Used when the time remaining is less than a day away."
|
||||
values={{
|
||||
courseEndTime,
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const courseEndDate = (
|
||||
<FormattedDate
|
||||
key="courseEndDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
msg = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
defaultMessage="This course is ending {timeRemaining} on {courseEndDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
courseEndDate,
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>{msg}</strong><br />
|
||||
{description}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CourseEndAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
description: PropTypes.string,
|
||||
endDate: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseEndAlert;
|
||||
41
src/course-home/outline-tab/alerts/course-end-alert/hooks.js
Normal file
41
src/course-home/outline-tab/alerts/course-end-alert/hooks.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const CourseEndAlert = React.lazy(() => import('./CourseEndAlert'));
|
||||
|
||||
// period of time (in ms) before end of course during which we alert
|
||||
const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
||||
|
||||
export function useCourseEndAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const delta = endBlock ? endDate - new Date() : 0;
|
||||
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
||||
const payload = {
|
||||
description: endBlock && endBlock.description,
|
||||
endDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseEndAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseEndAlert: CourseEndAlert,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useCourseEndAlert as default } from './hooks';
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
function CourseStartAlert({ payload }) {
|
||||
const {
|
||||
startDate,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.short"
|
||||
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
|
||||
description="Used when the time remaining is less than a day away."
|
||||
values={{
|
||||
courseStartTime: (
|
||||
<FormattedTime
|
||||
key="courseStartTime"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
courseStartDate: (
|
||||
<FormattedDate
|
||||
key="courseStartDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.calendar"
|
||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CourseStartAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
startDate: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseStartAlert;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
||||
|
||||
function useCourseStartAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
|
||||
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
|
||||
const isVisible = isEnrolled && startBlock && delta > 0;
|
||||
const payload = {
|
||||
startDate: startBlock && startBlock.date,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseStartAlert: CourseStartAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCourseStartAlert;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
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 { 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 outlineMessages from '../../messages';
|
||||
import { useEnrollClickHandler } from '../../../../alerts/enrollment-alert/hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
function PrivateCourseAlert({ intl, payload }) {
|
||||
const {
|
||||
anonymousUser,
|
||||
canEnroll,
|
||||
courseId,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
org,
|
||||
title,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
org,
|
||||
intl.formatMessage(enrollmentMessages.success),
|
||||
);
|
||||
|
||||
const enrollNow = (
|
||||
<Button
|
||||
disabled={loading}
|
||||
variant="link"
|
||||
className="p-0 border-0 align-top"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
onClick={enrollClickHandler}
|
||||
>
|
||||
{intl.formatMessage(enrollmentMessages.enrollNowInline)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const register = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.registerLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
const signIn = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.signInSentenceCase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type="welcome">
|
||||
{anonymousUser && (
|
||||
<>
|
||||
<p className="font-weight-bold">
|
||||
{intl.formatMessage(enrollmentMessages.alert)}
|
||||
</p>
|
||||
<FormattedMessage
|
||||
id="learning.privateCourse.signInOrRegister"
|
||||
description="Prompts the user to sign in or register to see course content."
|
||||
defaultMessage="{signIn} or {register} and then enroll in this course."
|
||||
values={{
|
||||
signIn,
|
||||
register,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!anonymousUser && (
|
||||
<>
|
||||
<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 }}
|
||||
/>
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</>
|
||||
)}
|
||||
{!canEnroll && (
|
||||
<>
|
||||
{intl.formatMessage(enrollmentMessages.alert)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
PrivateCourseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
anonymousUser: PropTypes.bool,
|
||||
canEnroll: PropTypes.bool,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PrivateCourseAlert);
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { ALERT_TYPES, useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const PrivateCourseAlert = React.lazy(() => import('./PrivateCourseAlert'));
|
||||
|
||||
export function usePrivateCourseAlert(courseId) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const course = useModel('courseHomeMeta', courseId);
|
||||
const outline = useModel('outline', courseId);
|
||||
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if the user is not enrolled AND
|
||||
* 1. the user is anonymous AND the outline is private, OR
|
||||
* 2. the user is authenticated.
|
||||
* */
|
||||
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
|
||||
const payload = {
|
||||
anonymousUser: authenticatedUser === null,
|
||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||
courseId,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientPrivateCourseAlert',
|
||||
dismissible: false,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-private-alerts',
|
||||
type: ALERT_TYPES.WELCOME,
|
||||
});
|
||||
|
||||
return { clientPrivateCourseAlert: PrivateCourseAlert };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePrivateCourseAlert as default } from './hooks';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
enroll: {
|
||||
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.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
1
src/course-home/outline-tab/index.js
Normal file
1
src/course-home/outline-tab/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './OutlineTab';
|
||||
221
src/course-home/outline-tab/messages.js
Normal file
221
src/course-home/outline-tab/messages.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
allDates: {
|
||||
id: 'learning.outline.dates.all',
|
||||
defaultMessage: 'View all course dates',
|
||||
},
|
||||
collapseAll: {
|
||||
id: 'learning.outline.collapseAll',
|
||||
defaultMessage: 'Collapse all',
|
||||
description: 'Label for button to close all of the collapsible sections',
|
||||
},
|
||||
completedAssignment: {
|
||||
id: 'learning.outline.completedAssignment',
|
||||
defaultMessage: 'Completed',
|
||||
description: 'Text used to describe the green checkmark icon in front of an assignment title',
|
||||
},
|
||||
completedSection: {
|
||||
id: 'learning.outline.completedSection',
|
||||
defaultMessage: 'Completed section',
|
||||
description: 'Text used to describe the green checkmark icon in front of a section title',
|
||||
},
|
||||
dates: {
|
||||
id: 'learning.outline.dates',
|
||||
defaultMessage: 'Important dates',
|
||||
},
|
||||
editGoal: {
|
||||
id: 'learning.outline.editGoal',
|
||||
defaultMessage: 'Edit goal',
|
||||
description: 'Edit course goal button',
|
||||
},
|
||||
expandAll: {
|
||||
id: 'learning.outline.expandAll',
|
||||
defaultMessage: 'Expand all',
|
||||
description: 'Label for button to open all of the collapsible sections',
|
||||
},
|
||||
goal: {
|
||||
id: 'learning.outline.goal',
|
||||
defaultMessage: 'Goal',
|
||||
description: 'Label for the selected course goal',
|
||||
},
|
||||
goalUnsure: {
|
||||
id: 'learning.outline.goalUnsure',
|
||||
defaultMessage: 'Not sure yet',
|
||||
},
|
||||
handouts: {
|
||||
id: 'learning.outline.handouts',
|
||||
defaultMessage: 'Course Handouts',
|
||||
},
|
||||
incompleteAssignment: {
|
||||
id: 'learning.outline.incompleteAssignment',
|
||||
defaultMessage: 'Incomplete',
|
||||
description: 'Text used to describe the gray checkmark icon in front of an assignment title',
|
||||
},
|
||||
incompleteSection: {
|
||||
id: 'learning.outline.incompleteSection',
|
||||
defaultMessage: 'Incomplete section',
|
||||
description: 'Text used to describe the gray checkmark icon in front of a section title',
|
||||
},
|
||||
learnMore: {
|
||||
id: 'learning.outline.learnMore',
|
||||
defaultMessage: 'Learn More',
|
||||
},
|
||||
openSection: {
|
||||
id: 'learning.outline.altText.openSection',
|
||||
defaultMessage: 'Open',
|
||||
description: 'A button to open the given section of the course outline',
|
||||
},
|
||||
resume: {
|
||||
id: 'learning.outline.resume',
|
||||
defaultMessage: 'Resume course',
|
||||
},
|
||||
setGoal: {
|
||||
id: 'learning.outline.setGoal',
|
||||
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
|
||||
},
|
||||
start: {
|
||||
id: 'learning.outline.start',
|
||||
defaultMessage: 'Start Course',
|
||||
},
|
||||
tools: {
|
||||
id: 'learning.outline.tools',
|
||||
defaultMessage: 'Course Tools',
|
||||
},
|
||||
upgradeButton: {
|
||||
id: 'learning.outline.upgradeButton',
|
||||
defaultMessage: 'Upgrade ({symbol}{price})',
|
||||
},
|
||||
upgradeTitle: {
|
||||
id: 'learning.outline.upgradeTitle',
|
||||
defaultMessage: 'Pursue a verified certificate',
|
||||
},
|
||||
certAlt: {
|
||||
id: 'learning.outline.certificateAlt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
welcomeMessage: {
|
||||
id: 'learning.outline.welcomeMessage',
|
||||
defaultMessage: 'Welcome Message',
|
||||
},
|
||||
welcomeMessageShowMoreButton: {
|
||||
id: 'learning.outline.welcomeMessageShowMoreButton',
|
||||
defaultMessage: 'Show More',
|
||||
},
|
||||
welcomeMessageShowLessButton: {
|
||||
id: 'learning.outline.welcomeMessageShowLessButton',
|
||||
defaultMessage: 'Show Less',
|
||||
},
|
||||
welcomeTo: {
|
||||
id: 'learning.outline.goalWelcome',
|
||||
defaultMessage: 'Welcome to',
|
||||
description: 'This precedes the title of the course',
|
||||
},
|
||||
proctoringInfoPanel: {
|
||||
id: 'learning.proctoringPanel.header',
|
||||
defaultMessage: 'This course contains proctored exams',
|
||||
},
|
||||
notStartedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.notStarted',
|
||||
defaultMessage: 'Not Started',
|
||||
},
|
||||
startedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.started',
|
||||
defaultMessage: 'Started',
|
||||
},
|
||||
submittedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.submitted',
|
||||
defaultMessage: 'Submitted',
|
||||
},
|
||||
verifiedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.verified',
|
||||
defaultMessage: 'Verified',
|
||||
},
|
||||
rejectedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
errorProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.error',
|
||||
defaultMessage: 'Error',
|
||||
},
|
||||
otherCourseApprovedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.otherCourseApproved',
|
||||
defaultMessage: 'Approved in Another Course',
|
||||
},
|
||||
expiringSoonProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expiringSoon',
|
||||
defaultMessage: 'Expiring Soon',
|
||||
},
|
||||
proctoringCurrentStatus: {
|
||||
id: 'learning.proctoringPanel.status',
|
||||
defaultMessage: 'Current Onboarding Status:',
|
||||
},
|
||||
notStartedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.notStarted',
|
||||
defaultMessage: 'You have not started your onboarding exam.',
|
||||
},
|
||||
startedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.started',
|
||||
defaultMessage: 'You have started your onboarding exam.',
|
||||
},
|
||||
submittedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.submitted',
|
||||
defaultMessage: 'You have submitted your onboarding exam.',
|
||||
},
|
||||
verifiedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.verified',
|
||||
defaultMessage: 'Your onboarding exam has been approved in this course.',
|
||||
},
|
||||
rejectedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.rejected',
|
||||
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
|
||||
},
|
||||
errorProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.error',
|
||||
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
|
||||
},
|
||||
otherCourseApprovedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.otherCourseApproved',
|
||||
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. 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',
|
||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
||||
},
|
||||
proctoringPanelGeneralInfoSubmitted: {
|
||||
id: 'learning.proctoringPanel.generalInfoSubmitted',
|
||||
defaultMessage: 'Your submitted profile is in review.',
|
||||
},
|
||||
proctoringPanelGeneralTime: {
|
||||
id: 'learning.proctoringPanel.generalTime',
|
||||
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}',
|
||||
},
|
||||
proctoringReviewRequirementsButton: {
|
||||
id: 'learning.proctoringPanel.reviewRequirementsButton',
|
||||
defaultMessage: 'Review instructions and system requirements',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user