Compare commits
489 Commits
KristinAok
...
mashal-m/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a071eda78e | ||
|
|
29fa6b3858 | ||
|
|
119c0ff4fb | ||
|
|
00e7680c20 | ||
|
|
cbb419c256 | ||
|
|
12205de132 | ||
|
|
62465ec956 | ||
|
|
165097d061 | ||
|
|
570cdb4b2a | ||
|
|
391ea08b20 | ||
|
|
5604def491 | ||
|
|
b788b969c3 | ||
|
|
b7a3d5640a | ||
|
|
3a21d8c807 | ||
|
|
81442bebe9 | ||
|
|
168ed1e184 | ||
|
|
c8e32c3f46 | ||
|
|
51dd90741b | ||
|
|
f58d6d6d25 | ||
|
|
81a49bd755 | ||
|
|
2ae033160f | ||
|
|
32bd3190a6 | ||
|
|
645ac2cb5f | ||
|
|
ee80b24cba | ||
|
|
ee1d816cc8 | ||
|
|
e8ac2ffc7e | ||
|
|
62d3e95cc8 | ||
|
|
ce6771d7cc | ||
|
|
1dcde821b4 | ||
|
|
694e3ed6d5 | ||
|
|
ba843622c2 | ||
|
|
2d29827e6b | ||
|
|
2b9b3db5d3 | ||
|
|
2e90e214b4 | ||
|
|
ea2d7ed839 | ||
|
|
5ee61904d5 | ||
|
|
6232b0cb98 | ||
|
|
09542338a2 | ||
|
|
c3d345e642 | ||
|
|
ec2bf60345 | ||
|
|
b0c71e5291 | ||
|
|
dcd6847254 | ||
|
|
d2df9241c3 | ||
|
|
1871e491a7 | ||
|
|
03543c0af1 | ||
|
|
0c49658314 | ||
|
|
2a1173584e | ||
|
|
398330fa07 | ||
|
|
f92fc8c3a5 | ||
|
|
5e072949d6 | ||
|
|
2d132f114c | ||
|
|
c73ef26d8e | ||
|
|
97ca7fe6aa | ||
|
|
e95a59c6c8 | ||
|
|
5f9c441cd2 | ||
|
|
2e641ac6c9 | ||
|
|
22937918ab | ||
|
|
714f5d452c | ||
|
|
8ac9745261 | ||
|
|
340580cb41 | ||
|
|
5a99ca5c91 | ||
|
|
9943df49e4 | ||
|
|
855474d406 | ||
|
|
a78496a3f6 | ||
|
|
79b65dadca | ||
|
|
fc8f5d43e8 | ||
|
|
6232f40a74 | ||
|
|
bc0ff1ce65 | ||
|
|
5997b29cee | ||
|
|
d2de0632cd | ||
|
|
922cc2187a | ||
|
|
d9539796b5 | ||
|
|
e0acb501eb | ||
|
|
a03ffe2724 | ||
|
|
cbdf7ce064 | ||
|
|
7184e85b2b | ||
|
|
b5321d01e4 | ||
|
|
6c8ab1a4c9 | ||
|
|
01f9d8f50b | ||
|
|
764befd4bd | ||
|
|
7317c9424a | ||
|
|
d897663b73 | ||
|
|
2e4eb158f2 | ||
|
|
35b229bd1b | ||
|
|
4ebd569792 | ||
|
|
52235ebc1c | ||
|
|
aa380e8619 | ||
|
|
4cf0c7f4d7 | ||
|
|
743650a99e | ||
|
|
39d89bee9e | ||
|
|
a601e431b2 | ||
|
|
7519bbe28e | ||
|
|
4b90dcbfc3 | ||
|
|
54cb52cb6d | ||
|
|
6dbd3f49dd | ||
|
|
678502bb40 | ||
|
|
bf77fc7ca1 | ||
|
|
421a9a5d2b | ||
|
|
dfe44cae56 | ||
|
|
a88571dae8 | ||
|
|
a4ea334692 | ||
|
|
97a1cb4ffc | ||
|
|
5166bfe056 | ||
|
|
33e3765b19 | ||
|
|
a13e7d7389 | ||
|
|
a4ea1b54a4 | ||
|
|
cd430ebb5d | ||
|
|
630d44a8cc | ||
|
|
894e16ddf0 | ||
|
|
263c486330 | ||
|
|
b3d33667d4 | ||
|
|
b500546e8d | ||
|
|
cb9e0aa52f | ||
|
|
69ff5463b3 | ||
|
|
3b4561e142 | ||
|
|
cf3b3a27bc | ||
|
|
3bb7aa06bc | ||
|
|
4cea9e582b | ||
|
|
0c74bb5106 | ||
|
|
b082f3ed19 | ||
|
|
5d477cebb2 | ||
|
|
851e49f8fb | ||
|
|
09436dd175 | ||
|
|
53c8e01c28 | ||
|
|
ed2d816bbe | ||
|
|
7c067299fb | ||
|
|
4ee1570bfa | ||
|
|
91c548847b | ||
|
|
49440ffb45 | ||
|
|
6752447d94 | ||
|
|
75c6aadb09 | ||
|
|
9eceb355f6 | ||
|
|
df7786388c | ||
|
|
361de31e22 | ||
|
|
9e040ec8f1 | ||
|
|
8db8aeed71 | ||
|
|
04471e550b | ||
|
|
925ee97a76 | ||
|
|
65086af173 | ||
|
|
33923d9a69 | ||
|
|
080d31e934 | ||
|
|
f3c80ed39b | ||
|
|
1ca4eda08a | ||
|
|
6193c2d1b3 | ||
|
|
f8a1147571 | ||
|
|
edba1600dc | ||
|
|
9a07ad1501 | ||
|
|
b343ca7a74 | ||
|
|
b6d272e99d | ||
|
|
0fbb53ae86 | ||
|
|
ba06fd7c98 | ||
|
|
9396fbd9d4 | ||
|
|
57d880de70 | ||
|
|
bfad5cf684 | ||
|
|
b0378e1331 | ||
|
|
19d06d60be | ||
|
|
df91fef82e | ||
|
|
7e53ddb685 | ||
|
|
be72e36a3a | ||
|
|
fa5cf8f204 | ||
|
|
759d154e13 | ||
|
|
7c4200e9d3 | ||
|
|
e5e73e40ba | ||
|
|
1892edaade | ||
|
|
381be9a26b | ||
|
|
b3841ef446 | ||
|
|
5a897e4ea1 | ||
|
|
96ceab8b2f | ||
|
|
f9806d0759 | ||
|
|
a7b584c566 | ||
|
|
193a184142 | ||
|
|
3e76f7ac78 | ||
|
|
36062ff3a6 | ||
|
|
6257cb4b58 | ||
|
|
792d9eb758 | ||
|
|
cd84a15891 | ||
|
|
cafb881a61 | ||
|
|
fd94da0a43 | ||
|
|
1e41547b3e | ||
|
|
bf2f123367 | ||
|
|
0211ecf45e | ||
|
|
36ac129267 | ||
|
|
20d4c35d83 | ||
|
|
bbff8e719e | ||
|
|
5461c08169 | ||
|
|
ee88a12d8f | ||
|
|
9b316bd859 | ||
|
|
7e7eb83596 | ||
|
|
aaa367780d | ||
|
|
6d42ee9c6f | ||
|
|
41047f4c88 | ||
|
|
d83551c809 | ||
|
|
7c3088901d | ||
|
|
518c9ef6c2 | ||
|
|
ae97efaf2b | ||
|
|
361a099ed1 | ||
|
|
7f3757539a | ||
|
|
44f5132e2a | ||
|
|
53b19c9be3 | ||
|
|
abc374b60a | ||
|
|
af837fcac8 | ||
|
|
e328e3d597 | ||
|
|
559160213d | ||
|
|
878a4616f3 | ||
|
|
3028d79597 | ||
|
|
aa0de7663c | ||
|
|
acd91a1c31 | ||
|
|
b32817b3dd | ||
|
|
8b32e5892f | ||
|
|
76cf85f3d7 | ||
|
|
7d86c501a7 | ||
|
|
eeee32c100 | ||
|
|
95d88a054e | ||
|
|
550b15a16c | ||
|
|
715393d6ad | ||
|
|
19b4241020 | ||
|
|
09bd5bd748 | ||
|
|
89771cb56b | ||
|
|
3353ee2f9d | ||
|
|
7c1821382c | ||
|
|
b444d677b7 | ||
|
|
7178f28838 | ||
|
|
b07f22193c | ||
|
|
c6eba42120 | ||
|
|
7bb2266790 | ||
|
|
0a70f9b64e | ||
|
|
cfe4432c6b | ||
|
|
f7219b4f5d | ||
|
|
14a19b2794 | ||
|
|
8a9767cdd3 | ||
|
|
3cba1bbac4 | ||
|
|
9436770620 | ||
|
|
d03dd34009 | ||
|
|
9cdacde4dc | ||
|
|
a22ac3a776 | ||
|
|
7e19af44da | ||
|
|
57c3f3080e | ||
|
|
385635f5d1 | ||
|
|
a7f763cd2a | ||
|
|
c7c9c19771 | ||
|
|
1d3a779ef1 | ||
|
|
4f1a50ec24 | ||
|
|
72d18dc4f9 | ||
|
|
2197ec0c21 | ||
|
|
069ac9c234 | ||
|
|
3edf349969 | ||
|
|
a2516e9fcc | ||
|
|
554806e9ce | ||
|
|
ed13128fc4 | ||
|
|
373a2d88fc | ||
|
|
bcd54a4f4b | ||
|
|
c4cb0e5ac2 | ||
|
|
c77d518d04 | ||
|
|
703250c3d2 | ||
|
|
35ec314505 | ||
|
|
9fc7951576 | ||
|
|
4ed350c9c6 | ||
|
|
ebed27529c | ||
|
|
24ced5dc63 | ||
|
|
f004d0ab3c | ||
|
|
1bbcc6d052 | ||
|
|
3d122e0fb9 | ||
|
|
685d2d5593 | ||
|
|
97bd45cfa8 | ||
|
|
55dac2696e | ||
|
|
4586f8a6ad | ||
|
|
88bc1f6956 | ||
|
|
4f2f17beb3 | ||
|
|
8114750796 | ||
|
|
7b945a9fce | ||
|
|
48aad3951a | ||
|
|
dcf8da2279 | ||
|
|
d8e1124a4c | ||
|
|
e9f0a658d6 | ||
|
|
7049445969 | ||
|
|
f17a635e9d | ||
|
|
cc8ee33dcd | ||
|
|
c25ec8f1ae | ||
|
|
8325851813 | ||
|
|
a66d2cf524 | ||
|
|
628ede3ccc | ||
|
|
c0c51a3028 | ||
|
|
947e5e3cb2 | ||
|
|
93baa10141 | ||
|
|
c02bf1eeed | ||
|
|
b4c90ab506 | ||
|
|
e20bed64fb | ||
|
|
8285d42b7e | ||
|
|
74484b7847 | ||
|
|
45d5141769 | ||
|
|
3c52eb2e8d | ||
|
|
616027df86 | ||
|
|
93790464f8 | ||
|
|
c2cb5744a1 | ||
|
|
5d62cb2f46 | ||
|
|
0f11fd6245 | ||
|
|
2d6e4063ed | ||
|
|
7b6f5ccf86 | ||
|
|
61f0ce2023 | ||
|
|
5706adde4d | ||
|
|
ec1c3da725 | ||
|
|
64b0c03d30 | ||
|
|
3b33aacb3d | ||
|
|
f907c588c9 | ||
|
|
99cf1f9f06 | ||
|
|
7f016e55aa | ||
|
|
f0f8027de4 | ||
|
|
fd3d0f9391 | ||
|
|
3fe5bb1733 | ||
|
|
6db421eade | ||
|
|
b9d1bf0624 | ||
|
|
2789c7415b | ||
|
|
8484d98e26 | ||
|
|
b346b741d5 | ||
|
|
eedaa9f2e9 | ||
|
|
f2f0cb6008 | ||
|
|
b61057f2df | ||
|
|
2d46bacdc7 | ||
|
|
4655b344a7 | ||
|
|
41207e953e | ||
|
|
16a6eeab24 | ||
|
|
907892e7bb | ||
|
|
f5d1b1c897 | ||
|
|
5854afa987 | ||
|
|
2aa2e42595 | ||
|
|
edf9e58d6d | ||
|
|
d344b501ab | ||
|
|
2bf4f2a0b5 | ||
|
|
de49e8b271 | ||
|
|
fb21f88c02 | ||
|
|
1044d2afc6 | ||
|
|
aaf2856573 | ||
|
|
1546c62e7f | ||
|
|
b8875f3cda | ||
|
|
febc0cae0b | ||
|
|
cc0c3c24d9 | ||
|
|
2fa4a837b1 | ||
|
|
32e299e13b | ||
|
|
f92d2e2ecd | ||
|
|
fffc48b41a | ||
|
|
0cc2dcdbc5 | ||
|
|
e9ca92a359 | ||
|
|
439965847a | ||
|
|
436c05487a | ||
|
|
fba300bc5c | ||
|
|
8c43de9fc0 | ||
|
|
c2b46d50a8 | ||
|
|
e2ce54dea8 | ||
|
|
af45d899e3 | ||
|
|
15a4ea42b2 | ||
|
|
555dddf8de | ||
|
|
b03e0fd904 | ||
|
|
a0c2e86a95 | ||
|
|
39682badef | ||
|
|
45afc3fbee | ||
|
|
15d20dd693 | ||
|
|
2d77ad7125 | ||
|
|
33df4d2b7f | ||
|
|
09b16976fd | ||
|
|
1b430f99fe | ||
|
|
982f849f41 | ||
|
|
6ec3a4cb5a | ||
|
|
4d29b202b1 | ||
|
|
99ee1da598 | ||
|
|
b896a64853 | ||
|
|
94ab6d016e | ||
|
|
41006f5cbf | ||
|
|
9c2c1427e1 | ||
|
|
c19f21d257 | ||
|
|
1d98de1e0c | ||
|
|
64eb268cb0 | ||
|
|
f7428db3c3 | ||
|
|
7986db7027 | ||
|
|
7704a8a5d7 | ||
|
|
d88f83311c | ||
|
|
6f0a69b838 | ||
|
|
7ed1be1960 | ||
|
|
663559f8c7 | ||
|
|
4a56673377 | ||
|
|
d1f19a9dc4 | ||
|
|
8a3722a723 | ||
|
|
4abf6ebdce | ||
|
|
d1013802ba | ||
|
|
581e8c4769 | ||
|
|
ea5c7f516a | ||
|
|
ce7cef0c6b | ||
|
|
45a823e6c7 | ||
|
|
0b8cf06c29 | ||
|
|
f93519f675 | ||
|
|
c39b3ae4c5 | ||
|
|
c3ea12225d | ||
|
|
f914d83510 | ||
|
|
67ea30a45a | ||
|
|
eabbb440f0 | ||
|
|
8735f219e9 | ||
|
|
c2414ce1ba | ||
|
|
e6fee7b5b9 | ||
|
|
d8f3c7441e | ||
|
|
765bf2089c | ||
|
|
10fce146fd | ||
|
|
b274cb5137 | ||
|
|
a6e539dad2 | ||
|
|
83fa3f78bc | ||
|
|
1e4f3ec151 | ||
|
|
1ac806b7dd | ||
|
|
1d08618be9 | ||
|
|
b90a54759c | ||
|
|
a1ef37ca0b | ||
|
|
d178913e4b | ||
|
|
9f2ce9d152 | ||
|
|
d6722ca271 | ||
|
|
aa2004434e | ||
|
|
921f3eef06 | ||
|
|
8337fc79be | ||
|
|
2230173da8 | ||
|
|
09072f318b | ||
|
|
1f7cb2cc28 | ||
|
|
c6ad7c51d3 | ||
|
|
20d4de09d7 | ||
|
|
5aa857f1de | ||
|
|
3fcc0d87c9 | ||
|
|
0bc7faaa56 | ||
|
|
0889b17e85 | ||
|
|
f57f39a787 | ||
|
|
20390d1e33 | ||
|
|
55b3396acd | ||
|
|
dae0c8931c | ||
|
|
dd6a499cfc | ||
|
|
0c006e28de | ||
|
|
bdba0d2c3c | ||
|
|
965a299f6c | ||
|
|
7cfeaab330 | ||
|
|
76e3173fc4 | ||
|
|
e1d3b91dca | ||
|
|
0592c40496 | ||
|
|
175a40d9fa | ||
|
|
192a58ab51 | ||
|
|
7215db6682 | ||
|
|
cb29902152 | ||
|
|
2932d98976 | ||
|
|
8c0e98ad4f | ||
|
|
8b9dfd2f08 | ||
|
|
e0a81d6cc9 | ||
|
|
9cdaa64f64 | ||
|
|
54d96cc162 | ||
|
|
3da1fb6581 | ||
|
|
268e8b0b40 | ||
|
|
d93cb70966 | ||
|
|
90d6ea8137 | ||
|
|
73302d72cb | ||
|
|
666d9e6b38 | ||
|
|
fcc0cceb8d | ||
|
|
597ecb7b4e | ||
|
|
5ec0fec0ff | ||
|
|
74c75af34d | ||
|
|
9a1966a034 | ||
|
|
1868606ee8 | ||
|
|
70e2aa0203 | ||
|
|
bdfbbc0b75 | ||
|
|
fda9ab6bce | ||
|
|
04cc668e9b | ||
|
|
c91e5d5f58 | ||
|
|
22ca88c981 | ||
|
|
ffd03cb1de | ||
|
|
8cfe4bc099 | ||
|
|
191ef9c7b9 | ||
|
|
ac0813816f | ||
|
|
a614145e6d | ||
|
|
ca9a000fd2 | ||
|
|
9e2b2ec541 | ||
|
|
8552329739 | ||
|
|
90fc5f0024 | ||
|
|
d3c44f3984 | ||
|
|
a607fe4574 | ||
|
|
2d5e1caae7 | ||
|
|
5087353e88 | ||
|
|
2171c28825 | ||
|
|
adde6e3470 | ||
|
|
0d015be97e | ||
|
|
dfbdcee163 | ||
|
|
3ad7b9e95d | ||
|
|
aedee4f847 | ||
|
|
6878ef9fe1 | ||
|
|
04dc5a26ec | ||
|
|
33348eabbd | ||
|
|
c5a383dfdb | ||
|
|
1177c6e2e2 | ||
|
|
d6fdf1512f | ||
|
|
936885707d |
15
.env
15
.env
@@ -2,13 +2,21 @@
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
INSIGHTS_BASE_URL=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
||||
@@ -18,12 +26,15 @@ LOGOUT_URL=''
|
||||
LOGO_URL=''
|
||||
LOGO_TRADEMARK_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
FAVICON_URL=''
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
SEGMENT_KEY=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
SITE_NAME=''
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
|
||||
STUDIO_BASE_URL=''
|
||||
@@ -35,4 +46,4 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='development'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -17,10 +25,12 @@ 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
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
@@ -36,3 +46,6 @@ TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
13
.env.test
13
.env.test
@@ -2,13 +2,21 @@
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -17,10 +25,12 @@ 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
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
@@ -35,3 +45,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
|
||||
22
.eslintrc.js
22
.eslintrc.js
@@ -1,11 +1,17 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
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',
|
||||
},
|
||||
}],
|
||||
const config = createConfig('eslint', {
|
||||
rules: {
|
||||
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release.
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'no-restricted-exports': 'off',
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'react/no-unknown-property': 'off',
|
||||
'func-names': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
|
||||
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Run the workflow that adds new tickets that are either:
|
||||
# - labelled "DEPR"
|
||||
# - title starts with "[DEPR]"
|
||||
# - body starts with "Proposal Date" (this is the first template field)
|
||||
# to the org-wide DEPR project board
|
||||
|
||||
name: Add newly created DEPR issues to the DEPR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
routeissue:
|
||||
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
10
.github/workflows/commitlint.yml
vendored
Normal file
10
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Run commitlint on the commit messages in a pull request.
|
||||
|
||||
name: Lint Commit Messages
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
24
.github/workflows/validate.yml
vendored
24
.github/workflows/validate.yml
vendored
@@ -1,21 +1,23 @@
|
||||
name: validate
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
jobs:
|
||||
build:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 12
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -20,3 +22,6 @@ logs
|
||||
|
||||
# Local package dependencies
|
||||
module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
1
.husky/_/.gitignore
vendored
Normal file
1
.husky/_/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
31
.husky/_/husky.sh
Normal file
31
.husky/_/husky.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
@@ -1,8 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.frontend-app-learning]
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-learning]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
type = KEYVALUEJSON
|
||||
|
||||
|
||||
31
Makefile
31
Makefile
@@ -1,11 +1,10 @@
|
||||
transifex_resource = frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
@@ -38,15 +37,30 @@ push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
|
||||
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -60,7 +74,6 @@ validate:
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run is-es5
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
137
README.rst
137
README.rst
@@ -1,10 +1,12 @@
|
||||
#####################
|
||||
frontend-app-learning
|
||||
######################
|
||||
|
||||
|codecov| |license|
|
||||
|
||||
frontend-app-learning
|
||||
=========================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
********
|
||||
Purpose
|
||||
********
|
||||
|
||||
This is the Learning MFE (micro-frontend application), which renders all
|
||||
learner-facing course pages (like the course outline, the progress page,
|
||||
@@ -15,21 +17,58 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
|
||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
|
||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
Development
|
||||
-----------
|
||||
***************
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
- Run ``make dev.up.lms``
|
||||
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
.. code-block::
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-learning.git``
|
||||
|
||||
2. Use node v18.x.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
3. Install npm dependencies:
|
||||
|
||||
``cd frontend-app-learning && npm ci``
|
||||
|
||||
4. Start the dev server:
|
||||
|
||||
``npm start``
|
||||
|
||||
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::
|
||||
@@ -52,17 +91,17 @@ file (which is git-ignored) that defines where to find your local modules, for i
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
Deployment
|
||||
----------
|
||||
==========
|
||||
|
||||
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
|
||||
edX Developer Guide's section on
|
||||
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
|
||||
|
||||
Environment Variables
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
======================
|
||||
|
||||
This MFE is configured via environment variables supplied at build time.
|
||||
All micro-frontends have a shared set of required environment variables,
|
||||
@@ -71,6 +110,15 @@ as documented in the Open edX Developer Guide under
|
||||
|
||||
The learning micro-frontend also supports the following additional variables:
|
||||
|
||||
CREDIT_HELP_LINK_URL
|
||||
A link to resources to help explain what course credit is and how to earn it.
|
||||
|
||||
ENABLE_JUMPNAV
|
||||
Enables the new Jump Navigation feature in the course breadcrumbs, defaulted to the string 'true'.
|
||||
Disable to have simple hyperlinks for breadcrumbs. Setting it to any other value but 'true' ('false','I love flags', 'etc' would disable the Jumpnav).
|
||||
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
|
||||
https://openedx.atlassian.net/browse/TNL-8678
|
||||
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN
|
||||
This value is passed as the ``utm_campaign`` parameter for social-share
|
||||
links when celebrating learning milestones in the course. Optional.
|
||||
@@ -109,3 +157,60 @@ TWITTER_URL
|
||||
unless this is set. Optional.
|
||||
|
||||
Example: https://twitter.com/edXOnline
|
||||
|
||||
Getting Help
|
||||
===========
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-learning/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
The Open edX Code of Conduct
|
||||
============================
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
License
|
||||
=======
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Courseware Page Decisions
|
||||
|
||||
**See [0009-courseware-api-direction.md](0009-courseware-api-direction.md) for updates!**
|
||||
|
||||
## Courseware data loading
|
||||
|
||||
Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components.
|
||||
|
||||
62
docs/decisions/0009-courseware-api-direction.md
Normal file
62
docs/decisions/0009-courseware-api-direction.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Direction of Courseware APIs
|
||||
|
||||
In order to allow for greater flexibility and separation of concerns, we're going to stop using the Course Blocks API for navigational data, and pull that data from the Learning Sequences Outlines API instead. The intention is to give us four distinct layers of courseware that can eventually be composed in different ways:
|
||||
|
||||
* Learning Context Metadata
|
||||
* Learning Context Navigation
|
||||
* Sequence Navigation
|
||||
* Unit Rendering
|
||||
|
||||
Note that "Learning Context" is a generalization of "Course" that includes other things like Content Libraries, Learning Pathways, and potentially other logical groupings of content.
|
||||
|
||||
This is a refinement of [0002-courseware-page-decisions.md](0002-courseware-page-decisions.md). The fundamental layers remain the same, but this document tries to better clarify the boundaries and path forward for these layers. We're not making these layers completely swappable/pluggable now, but we should separate the data access in a way that allows for that in the future.
|
||||
|
||||
## Background
|
||||
|
||||
We currently make four primary requests to the LMS when rendering courseware instructional content:
|
||||
|
||||
1. Course Metadata: `/api/courseware/course/{courseId}` (REST API)
|
||||
2. Course Blocks API: `/api/courses/v2/blocks/?course_id={courseId}` (REST API)
|
||||
3. Sequence Metadata: `/api/courseware/sequence/{sequenceUsageKey}` (REST API)
|
||||
4. Unit: `/xblock/{unitBlockUsageKey}` (rendered in an iframe)
|
||||
|
||||
There is a significant amount of overlap between the Course Blocks API and the others at the moment, since Course Blocks takes a static snapshot of the entire tree of course content at once. There are a few problems with the current arrangement:
|
||||
|
||||
* It's slow and complex. The Course Blocks API can be difficult to maintain and reason about, and trickier to optimize.
|
||||
* Assuming that all course structures are the same makes it difficult to support other content types, like LabXchange Learning Pathways or adaptive content.
|
||||
* The overlap between Course Blocks and the other APIs means that there can be conflicts about the state.
|
||||
|
||||
## Motivating Vision
|
||||
|
||||
We have seen a desire to extend or enhance the courseware experience in various ways:
|
||||
|
||||
Learning Context Navigation
|
||||
* Allowing for shorter, human-readable URLs in courseware.
|
||||
* Smaller courses that do not need the current navigational hierarchy.
|
||||
* LabXchange pathways.
|
||||
|
||||
Sequence Navigation
|
||||
* Adaptive content, where the full list of units is not known up front.
|
||||
* More limited navigation, where content is pushed linearly, without the ability to jump ahead.
|
||||
* Different layouts for content browsing.
|
||||
|
||||
Unit Rendering
|
||||
* Use of QTI content (currently serviced by cc2olx conversion).
|
||||
* Desire to experiment with a next-gen version of XBlock.
|
||||
* Use of entirely LTI units.
|
||||
|
||||
The idea would be to insulate each layer from the layers above and below it. Sequence rendering shouldn't be affected by whether or not it's in a two level hierarchy (Course → Section → Sequence), or a flat one (Course → Sequence). Learning Context Navigation should be able to reference Sequences without caring if a Sequence is an adaptive one or not. Sequences should be able to have a common interface to call Unit iframes, whether those Units are rendering XBlocks or QTI content.
|
||||
|
||||
Note that supporting these types of course structures would require downstream changes in other systems as well (e.g. analytics).
|
||||
|
||||
## Next Step: Removing use of the Course Blocks API.
|
||||
|
||||
The next step in this process is to remove the call to the Course Blocks API, and split its responsibilities across just the existing Learning Sequences Outline and Sequence Metadata APIs. This will involve at least a couple of steps.
|
||||
|
||||
### Complete rollout of Learning Sequences Outline calls.
|
||||
|
||||
We're currently in a transitional state between these APIs where the Learning Sequences Outline calls are only rolled out on a small handful of courses.
|
||||
|
||||
### Shift Sequence and Unit metadata to only come from Sequence Metadata API.
|
||||
|
||||
We currently pull this information from both Course Blocks and the Sequence Metadata API. We can consolidate on just the Sequence Metadata API. There is also server side optimization that can be done with the Sequence Metadata API calls as part of this work.
|
||||
@@ -9,4 +9,12 @@ module.exports = createConfig('jest', {
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
// see https://github.com/axios/axios/issues/5026
|
||||
moduleNameMapper: {
|
||||
"^axios$": "axios/dist/axios.js",
|
||||
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
|
||||
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
testEnvironment: 'jsdom'
|
||||
});
|
||||
|
||||
45960
package-lock.json
generated
45960
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
85
package.json
85
package.json
@@ -4,16 +4,14 @@
|
||||
"description": "Frontend learning application.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-app-learning.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-learning.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"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 .",
|
||||
"prepare": "husky install",
|
||||
@@ -23,60 +21,63 @@
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.1.6",
|
||||
"@edx/frontend-enterprise-utils": "0.1.7",
|
||||
"@edx/frontend-lib-special-exams": "1.12.0",
|
||||
"@edx/frontend-platform": "1.12.3",
|
||||
"@edx/paragon": "16.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "12.2.1",
|
||||
"@edx/frontend-component-header": "4.6.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^1.16.0",
|
||||
"@edx/frontend-lib-special-exams": "2.23.3",
|
||||
"@edx/frontend-platform": "5.5.2",
|
||||
"@edx/paragon": "20.46.0",
|
||||
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.15",
|
||||
"@pact-foundation/pact": "9.16.0",
|
||||
"@reduxjs/toolkit": "1.6.1",
|
||||
"classnames": "2.3.1",
|
||||
"core-js": "3.16.1",
|
||||
"js-cookie": "2.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.7.2",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.4",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-share": "4.4.0",
|
||||
"redux": "4.1.1",
|
||||
"regenerator-runtime": "0.13.9",
|
||||
"reselect": "4.0.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.8",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.4"
|
||||
"util": "0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "8.0.0",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.14.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.8.3",
|
||||
"axios-mock-adapter": "1.19.0",
|
||||
"codecov": "3.8.3",
|
||||
"es-check": "5.2.4",
|
||||
"glob": "7.1.7",
|
||||
"husky": "7.0.1",
|
||||
"jest": "27.0.6",
|
||||
"jest-chain": "1.1.5",
|
||||
"reactifex": "1.1.1",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "^12.9.10",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"es-check": "6.2.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.5.0",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<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>
|
||||
<% } %>
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
@@ -8,19 +7,8 @@ import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
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 AccessExpirationAlert = ({ intl, payload }) => {
|
||||
const {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
@@ -36,24 +24,10 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
|
||||
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,
|
||||
@@ -73,6 +47,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.deadline"
|
||||
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
|
||||
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
@@ -105,6 +80,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
defaultMessage="Audit Access Expires {date}"
|
||||
description="Headline for auditing deadline"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
@@ -123,6 +99,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.body"
|
||||
defaultMessage="You lose all access to this course, including your progress, on {date}."
|
||||
description="Message body to tell learner the consequences of course expiration."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
@@ -139,7 +116,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
{deadlineMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
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 variant="info" icon={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);
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
function AccessExpirationAlertMasquerade({ payload }) {
|
||||
const {
|
||||
accessExpiration,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
masqueradingExpiredCourse,
|
||||
} = accessExpiration;
|
||||
|
||||
if (!masqueradingExpiredCourse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.expired"
|
||||
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationExpiredDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlertMasquerade.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlertMasquerade;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
||||
const {
|
||||
expirationDate,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<PageBanner variant="warning">
|
||||
<FormattedMessage
|
||||
id="instructorToolbar.pageBanner.courseHasExpired"
|
||||
defaultMessage="This learner no longer has access to this course. Their access expired on {date}."
|
||||
description="It's a warning that is shown to course author when being masqueraded as learner, while the course has expired for the real learner."
|
||||
values={{
|
||||
date: <FormattedDate
|
||||
key="instructorToolbar.pageBanner.accessExpirationDate"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</PageBanner>
|
||||
);
|
||||
};
|
||||
|
||||
AccessExpirationMasqueradeBanner.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationMasqueradeBanner;
|
||||
@@ -1,42 +1,51 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
const AccessExpirationAlertMasquerade = React.lazy(() => import('./AccessExpirationAlertMasquerade'));
|
||||
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
|
||||
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
const payload = {
|
||||
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
|
||||
const payload = useMemo(() => ({
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
};
|
||||
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
payload,
|
||||
topic,
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
export function useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, topic) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
const payload = {
|
||||
accessExpiration,
|
||||
export function useAccessExpirationMasqueradeBanner(courseId, tab) {
|
||||
const {
|
||||
userTimezone,
|
||||
};
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
accessExpiration,
|
||||
} = useModel(tab, courseId);
|
||||
|
||||
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
|
||||
const expirationDate = accessExpiration && accessExpiration.expirationDate;
|
||||
const payload = useMemo(() => ({
|
||||
expirationDate,
|
||||
userTimezone,
|
||||
}), [expirationDate, userTimezone]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlertMasquerade',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic,
|
||||
code: 'clientAccessExpirationMasqueradeBanner',
|
||||
payload,
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlertMasquerade: AccessExpirationAlertMasquerade };
|
||||
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default, useAccessExpirationAlertMasquerade } from './hooks';
|
||||
export { default, useAccessExpirationMasqueradeBanner } from './hooks';
|
||||
|
||||
@@ -4,6 +4,7 @@ const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.accessExpiration.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'The anchor text for the upgrading link',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
48
src/alerts/active-enteprise-alert/ActiveEnterpriseAlert.jsx
Normal file
48
src/alerts/active-enteprise-alert/ActiveEnterpriseAlert.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import genericMessages from './messages';
|
||||
|
||||
const ActiveEnterpriseAlert = ({ intl, payload }) => {
|
||||
const { text, courseId } = payload;
|
||||
const changeActiveEnterprise = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={
|
||||
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
|
||||
`${global.location.origin}/course/${courseId}/home`,
|
||||
)}`
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
{text}
|
||||
<FormattedMessage
|
||||
id="learning.activeEnterprise.alert"
|
||||
description="Prompts the user to log-in with the correct enterprise to access the course content."
|
||||
defaultMessage=" {changeActiveEnterprise}."
|
||||
values={{
|
||||
changeActiveEnterprise,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveEnterpriseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ActiveEnterpriseAlert);
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
initializeTestStore, render, screen,
|
||||
} from '../../setupTest';
|
||||
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
|
||||
|
||||
describe('ActiveEnterpriseAlert', () => {
|
||||
const mockData = {
|
||||
payload: {
|
||||
text: 'test message',
|
||||
courseId: 'test-course-id',
|
||||
},
|
||||
};
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
});
|
||||
|
||||
it('Shows alert message and links', () => {
|
||||
render(<ActiveEnterpriseAlert {...mockData} />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`);
|
||||
});
|
||||
});
|
||||
28
src/alerts/active-enteprise-alert/hooks.js
Normal file
28
src/alerts/active-enteprise-alert/hooks.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
|
||||
|
||||
export default function useActiveEnterpriseAlert(courseId) {
|
||||
const { courseAccess } = useModel('courseHomeMeta', courseId);
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. course access code is incorrect_active_enterprise
|
||||
*/
|
||||
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
text: courseAccess && courseAccess.userMessage,
|
||||
courseId,
|
||||
}), [courseAccess, courseId]);
|
||||
useAlert(isVisible, {
|
||||
code: 'clientActiveEnterpriseAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
payload,
|
||||
});
|
||||
|
||||
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
|
||||
}
|
||||
3
src/alerts/active-enteprise-alert/index.js
Normal file
3
src/alerts/active-enteprise-alert/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import useActiveEnterpriseAlert from './hooks';
|
||||
|
||||
export default useActiveEnterpriseAlert;
|
||||
11
src/alerts/active-enteprise-alert/messages.js
Normal file
11
src/alerts/active-enteprise-alert/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
changeActiveEnterpriseLowercase: {
|
||||
id: 'learning.activeEnterprise.change.alert',
|
||||
defaultMessage: 'change enterprise now',
|
||||
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,31 +3,41 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedRelativeTime,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function CourseStartAlert({ payload }) {
|
||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
||||
|
||||
const CourseStartAlert = ({ payload }) => {
|
||||
const {
|
||||
startDate,
|
||||
userTimezone,
|
||||
courseId,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
start: startDate,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
<FormattedRelativeTime
|
||||
key="timeRemaining"
|
||||
value={startDate}
|
||||
value={delta / 1000}
|
||||
numeric="auto"
|
||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
||||
updateIntervalInSeconds={YEAR_SEC}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
@@ -58,7 +68,7 @@ function CourseStartAlert({ payload }) {
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
id="learning.outline.alert.start.long"
|
||||
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
@@ -78,17 +88,17 @@ function CourseStartAlert({ payload }) {
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.calendar"
|
||||
id="learning.outline.alert.start.calendar"
|
||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
||||
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CourseStartAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
startDate: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const CourseStartMasqueradeBanner = ({ payload }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
start,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<PageBanner variant="warning">
|
||||
<FormattedMessage
|
||||
id="instructorToolbar.pageBanner.courseHasNotStarted"
|
||||
defaultMessage="This learner does not yet have access to this course. The course starts on {date}."
|
||||
description="It's a warning that is shown to course author when being masqueraded as learner, while the course hasn't started for the real learner yet."
|
||||
values={{
|
||||
date: <FormattedDate
|
||||
key="instructorToolbar.pageBanner.courseStartDate"
|
||||
value={start}
|
||||
{...timezoneFormatArgs}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</PageBanner>
|
||||
);
|
||||
};
|
||||
|
||||
CourseStartMasqueradeBanner.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseStartMasqueradeBanner;
|
||||
62
src/alerts/course-start-alert/hooks.js
Normal file
62
src/alerts/course-start-alert/hooks.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
||||
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
|
||||
|
||||
function IsStartDateInFuture(courseId) {
|
||||
const {
|
||||
start,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const today = new Date();
|
||||
const startDate = new Date(start);
|
||||
return startDate > today;
|
||||
}
|
||||
|
||||
function useCourseStartAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
courseId,
|
||||
}), [courseId]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartAlert',
|
||||
payload,
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseStartAlert: CourseStartAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCourseStartMasqueradeBanner(courseId, tab) {
|
||||
const {
|
||||
isMasquerading,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId);
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
courseId,
|
||||
}), [courseId]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartMasqueradeBanner',
|
||||
payload,
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseStartMasqueradeBanner: CourseStartMasqueradeBanner,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCourseStartAlert;
|
||||
1
src/alerts/course-start-alert/index.js
Normal file
1
src/alerts/course-start-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default, useCourseStartMasqueradeBanner } from './hooks';
|
||||
@@ -11,7 +11,7 @@ import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
import useEnrollClickHandler from './clickHook';
|
||||
|
||||
function EnrollmentAlert({ intl, payload }) {
|
||||
const EnrollmentAlert = ({ intl, payload }) => {
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
@@ -55,7 +55,7 @@ function EnrollmentAlert({ intl, payload }) {
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -27,7 +27,7 @@ function useEnrollClickHandler(courseId, orgId, successText) {
|
||||
});
|
||||
global.location.reload();
|
||||
});
|
||||
}, [courseId]);
|
||||
}, [addFlash, courseId, orgId, successText]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
@@ -22,16 +22,16 @@ export function useEnrollmentAlert(courseId) {
|
||||
* 3. the course is private.
|
||||
*/
|
||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
||||
const payload = {
|
||||
const payload = useMemo(() => ({
|
||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||
courseId,
|
||||
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
||||
isStaff: course && course.isStaff,
|
||||
};
|
||||
}), [course, courseId, outline]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientEnrollmentAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
payload,
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Check, ArrowForward } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { sendActivationEmail } from '../../courseware/data';
|
||||
import messages from './messages';
|
||||
|
||||
function AccountActivationAlert() {
|
||||
const AccountActivationAlert = ({
|
||||
intl,
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
@@ -29,22 +32,12 @@ function AccountActivationAlert() {
|
||||
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
|
||||
// of cookie would make it infinite 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"
|
||||
@@ -64,7 +57,7 @@ function AccountActivationAlert() {
|
||||
);
|
||||
|
||||
const children = () => {
|
||||
let bodyContent = null;
|
||||
let bodyContent;
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.message"
|
||||
@@ -123,13 +116,17 @@ function AccountActivationAlert() {
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={showModal}
|
||||
title={title}
|
||||
title={intl.formatMessage(messages.accountActivationAlertTitle)}
|
||||
footerNode={button}
|
||||
onClose={() => ({})}
|
||||
>
|
||||
{children()}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AccountActivationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccountActivationAlert);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
function LogistrationAlert({ intl }) {
|
||||
const LogistrationAlert = ({ intl }) => {
|
||||
const signIn = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
@@ -41,7 +41,7 @@ function LogistrationAlert({ intl }) {
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
LogistrationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
11
src/alerts/logistration-alert/messages.js
Normal file
11
src/alerts/logistration-alert/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
accountActivationAlertTitle: {
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
57
src/alerts/sequence-alerts/hooks.js
Normal file
57
src/alerts/sequence-alerts/hooks.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function useSequenceBannerTextAlert(sequenceId) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
// Show Alert that comes along with the sequence
|
||||
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const {
|
||||
entranceExamCurrentScore,
|
||||
entranceExamEnabled,
|
||||
entranceExamId,
|
||||
entranceExamMinimumScorePct,
|
||||
entranceExamPassed,
|
||||
} = course.entranceExamData || {};
|
||||
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
|
||||
let entranceExamText;
|
||||
|
||||
if (entranceExamPassed) {
|
||||
entranceExamText = intl.formatMessage(
|
||||
messages.entranceExamTextPassed,
|
||||
{ entranceExamCurrentScore: entranceExamCurrentScore * 100 },
|
||||
);
|
||||
} else {
|
||||
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
|
||||
entranceExamCurrentScore: entranceExamCurrentScore * 100,
|
||||
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
|
||||
});
|
||||
}
|
||||
|
||||
useAlert(entranceExamAlertVisible, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: entranceExamText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };
|
||||
14
src/alerts/sequence-alerts/messages.js
Normal file
14
src/alerts/sequence-alerts/messages.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
entranceExamTextNotPassing: {
|
||||
id: 'learn.sequence.entranceExamTextNotPassing',
|
||||
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
|
||||
},
|
||||
entranceExamTextPassed: {
|
||||
id: 'learn.sequence.entranceExamTextPassed',
|
||||
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
33
src/constants.js
Normal file
33
src/constants.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export const DECODE_ROUTES = {
|
||||
ACCESS_DENIED: '/course/:courseId/access-denied',
|
||||
HOME: '/course/:courseId/home',
|
||||
LIVE: '/course/:courseId/live',
|
||||
DATES: '/course/:courseId/dates',
|
||||
DISCUSSION: '/course/:courseId/discussion/:path/*',
|
||||
PROGRESS: [
|
||||
'/course/:courseId/progress/:targetUserId/',
|
||||
'/course/:courseId/progress',
|
||||
],
|
||||
COURSE_END: '/course/:courseId/course-end',
|
||||
COURSEWARE: [
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
],
|
||||
REDIRECT_HOME: 'home/:courseId',
|
||||
REDIRECT_SURVEY: 'survey/:courseId',
|
||||
};
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
CONSENT: 'consent',
|
||||
};
|
||||
|
||||
export const REDIRECT_MODES = {
|
||||
DASHBOARD_REDIRECT: 'dashboard-redirect',
|
||||
CONSENT_REDIRECT: 'consent-redirect',
|
||||
HOME_REDIRECT: 'home-redirect',
|
||||
SURVEY_REDIRECT: 'survey-redirect',
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
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);
|
||||
@@ -1,76 +0,0 @@
|
||||
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 = {
|
||||
intl: intlShape.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
enterpriseLearnerPortalLink: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
AuthenticatedUserDropdown.defaultProps = {
|
||||
enterpriseLearnerPortalLink: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(AuthenticatedUserDropdown);
|
||||
@@ -1,34 +0,0 @@
|
||||
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,97 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import AnonymousUserMenu from './AnonymousUserMenu';
|
||||
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
|
||||
import messages from './messages';
|
||||
|
||||
function LinkedLogo({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) {
|
||||
return (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
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">
|
||||
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
|
||||
<div className="container-xl py-2 d-flex align-items-center">
|
||||
{headerLogo}
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
</div>
|
||||
{authenticatedUser && (
|
||||
<AuthenticatedUserDropdown
|
||||
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
|
||||
username={authenticatedUser.username}
|
||||
/>
|
||||
)}
|
||||
{!authenticatedUser && (
|
||||
<AnonymousUserMenu />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Header);
|
||||
@@ -1,29 +0,0 @@
|
||||
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,46 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
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;
|
||||
95
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
95
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import {
|
||||
Close,
|
||||
} from '@edx/paragon/icons';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useElementBoundingBox, useLockScroll } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const CoursewareSearch = ({ intl, ...sectionProps }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useLockScroll();
|
||||
|
||||
const info = useElementBoundingBox('courseTabsNavigation');
|
||||
const top = info ? `${Math.floor(info.top)}px` : 0;
|
||||
|
||||
return (
|
||||
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
|
||||
<div className="courseware-search__close">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-1"
|
||||
aria-label={intl.formatMessage(messages.searchCloseAction)}
|
||||
onClick={() => dispatch(setShowSearch(false))}
|
||||
data-testid="courseware-search-close-button"
|
||||
><Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="courseware-search__outer-content">
|
||||
<div className="courseware-search__content" style={{ height: '999px' }}>
|
||||
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis semper rutrum odio quis congue.
|
||||
Duis sodales nibh et sapien elementum fermentum. Quisque magna urna, gravida at gravida et,
|
||||
ultricies vel massa.Aliquam in vehicula dolor, id scelerisque felis.
|
||||
Morbi posuere scelerisque tincidunt. Proin et gravida tortor. Vestibulum vel orci vulputate,
|
||||
gravida justo eu, varius dolor. Etiam viverra diam sed est tincidunt, et aliquam est efficitur.
|
||||
Donec imperdiet eros quis est condimentum faucibus.
|
||||
</p>
|
||||
<p>
|
||||
In mattis, tellus ut lacinia viverra, ligula ex sagittis ex, sed mollis ex enim ut velit.
|
||||
Nunc elementum, risus eget feugiat scelerisque, sapien felis laoreet nisl, ut pharetra neque
|
||||
lorem a elit. Maecenas elementum, metus fringilla suscipit imperdiet, mi nunc efficitur elit,
|
||||
sed consequat massa magna sit amet dui. Curabitur ultrices nisi vel lorem scelerisque, pharetra
|
||||
luctus nunc pulvinar. Morbi aliquam ante eget arcu condimentum consectetur. Fusce faucibus lacus
|
||||
sed pretium ultrices. Curabitur neque lacus, elementum convallis augue placerat, gravida
|
||||
scelerisque ipsum. Donec bibendum lectus id ullamcorper sodales. Integer quis ante facilisis erat
|
||||
maximus viverra. Nunc rutrum posuere lectus, aliquam congue odio blandit nec. Phasellus placerat,
|
||||
magna non bibendum lacinia, tortor orci vulputate dui, vitae imperdiet turpis dui nec tortor.
|
||||
Praesent porttitor mollis diam ut gravida. Praesent vitae felis dignissim sem accumsan dignissim.
|
||||
Fusce ullamcorper bibendum ante ac pellentesque. Aliquam sed leo vel leo pellentesque cursus a at risus.
|
||||
Donec sollicitudin maximus diam, sit amet molestie sapien commodo at.
|
||||
</p>
|
||||
<p>
|
||||
Cras ornare pulvinar est id rhoncus. Aenean ut risus magna. Fusce cursus pulvinar dui ut egestas.
|
||||
Quisque condimentum risus non mi sagittis, eu facilisis enim hendrerit. Integer faucibus dapibus rutrum.
|
||||
Nullam vitae mollis tortor, eu lacinia mi. Nunc commodo ex id eros hendrerit, vel interdum augue tristique.
|
||||
Suspendisse ullamcorper, purus in vestibulum auctor, justo nisi finibus dolor,
|
||||
nec dignissim arcu enim a augue.
|
||||
</p>
|
||||
<p>
|
||||
Fusce vel libero odio. Orci varius natoque penatibus et magnis dis parturient montes,
|
||||
nascetur ridiculus mus. Pellentesque at varius turpis. Ut pulvinar efficitur congue. Vivamus cursus,
|
||||
purus at aliquet malesuada, felis quam blandit dolor, a interdum justo est semper augue.
|
||||
In eu lectus sit amet est pellentesque porta vel eget magna. Morbi sollicitudin turpis vitae faucibus
|
||||
pulvinar. Etiam placerat pulvinar porta.
|
||||
</p>
|
||||
<p>
|
||||
Suspendisse mattis eget felis non sagittis. Nulla facilisi. In bibendum cursus purus, non venenatis tellus
|
||||
dignissim sit amet. Phasellus volutpat ipsum turpis, non imperdiet nisi elementum a. Nunc mollis, sapien
|
||||
cursus vehicula consectetur, nunc turpis pulvinar mauris, at varius justo mi egestas nisi. Fusce semper
|
||||
sapien in orci rhoncus ornare. Donec maximus mi eu pulvinar convallis.
|
||||
</p>
|
||||
<p>
|
||||
Nullam tortor sem, hendrerit eu sapien ac, venenatis rhoncus ligula. Donec nibh leo, venenatis sed interdum
|
||||
ac, pharetra sed nibh. Orci varius natoque penatibus et magnis dis parturient montes,
|
||||
nascetur ridiculus mus. Sed congue risus eu mattis condimentum. In id nulla sit amet magna suscipit
|
||||
consectetur. Nullam vitae augue felis. In consequat tempus diam, a eleifend ante bibendum ac.
|
||||
Vivamus mi orci, fermentum ac viverra quis, tristique a ipsum. Morbi imperdiet porta sem, in sollicitudin
|
||||
risus dignissim at. Nulla dapibus iaculis vestibulum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearch.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoursewareSearch);
|
||||
84
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
84
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearch } from './index';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useElementBoundingBox, useLockScroll } from './hooks';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../data/slice');
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
const tabsTopPosition = 128;
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const { container } = render(<CoursewareSearch {...props} />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearch', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when rendering normally', () => {
|
||||
beforeAll(() => {
|
||||
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
|
||||
expect(useElementBoundingBox).toBeCalledTimes(1);
|
||||
expect(useLockScroll).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
|
||||
});
|
||||
|
||||
it('Should dispatch setShowSearch(true) when clicking the close button', () => {
|
||||
const button = screen.getByTestId('courseware-search-close-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when CourseTabsNavigation is not present', () => {
|
||||
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
|
||||
useElementBoundingBox.mockImplementation(() => undefined);
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passing extra props', () => {
|
||||
it('Should pass on extra props to section element', () => {
|
||||
renderComponent({ foo: 'bar' });
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section).toHaveAttribute('foo', 'bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
38
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
38
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { Search } from '@edx/paragon/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchFeatureFlag } from './hooks';
|
||||
|
||||
const CoursewareSearchToggle = ({
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="courseware-searc-toggle">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="p-1 mt-2 mr-2 rounded-lg"
|
||||
aria-label={intl.formatMessage(messages.searchOpenAction)}
|
||||
onClick={() => dispatch(setShowSearch(true))}
|
||||
data-testid="courseware-search-open-button"
|
||||
>
|
||||
<Icon src={Search} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchToggle.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoursewareSearchToggle);
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { CoursewareSearchToggle } from './index';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('../data/thunks');
|
||||
jest.mock('../data/slice');
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearchToggle />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should not render when the waffle flag is disabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
|
||||
|
||||
await act(async () => renderComponent());
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render when the waffle flag is enabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
await act(async () => renderComponent());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
await act(async () => renderComponent());
|
||||
const button = await screen.findByTestId('courseware-search-open-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
45
src/course-home/courseware-search/courseware-search.scss
Normal file
45
src/course-home/courseware-search/courseware-search.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
.courseware-search {
|
||||
background: white;
|
||||
position: fixed;
|
||||
top: var(--modal-top-position, 0);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-top: 1px solid $light-300;
|
||||
z-index: 200;
|
||||
|
||||
&__close {
|
||||
position: absolute !important; // For some reason it gets overridden
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__outer-content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 2rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
max-width: 42rem;
|
||||
margin: auto;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, 'md')) {
|
||||
.courseware-search__content {
|
||||
padding-top: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
body._search-no-scroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
71
src/course-home/courseware-search/hooks.js
Normal file
71
src/course-home/courseware-search/hooks.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useLayoutEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
|
||||
const DEBOUNCE_WAIT = 100; // ms
|
||||
|
||||
export function useCoursewareSearchFeatureFlag() {
|
||||
const { courseId } = useParams();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
|
||||
}, [courseId]);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function useCoursewareSearchState() {
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
const show = useSelector(state => state.courseHome.showSearch);
|
||||
|
||||
return { show: enabled && show };
|
||||
}
|
||||
|
||||
export function useElementBoundingBox(elementId) {
|
||||
const [info, setInfo] = useState(undefined);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (!element) {
|
||||
console.warn(`useElementBoundingBox(): Unable to find element with id='${elementId}' in the document.`); // eslint-disable-line no-console
|
||||
return undefined;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Handler to call on window resize and scroll
|
||||
function recalculate() {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
setInfo(bounds);
|
||||
}
|
||||
const debouncedRecalculate = debounce(recalculate, DEBOUNCE_WAIT, { leading: true });
|
||||
|
||||
// Add event listener
|
||||
global.addEventListener('resize', debouncedRecalculate);
|
||||
global.addEventListener('scroll', debouncedRecalculate);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
debouncedRecalculate();
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
global.removeEventListener('resize', debouncedRecalculate);
|
||||
global.removeEventListener('scroll', debouncedRecalculate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export function useLockScroll() {
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.body.classList.add('_search-no-scroll');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('_search-no-scroll');
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
187
src/course-home/courseware-search/hooks.test.jsx
Normal file
187
src/course-home/courseware-search/hooks.test.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import {
|
||||
useCoursewareSearchFeatureFlag, useCoursewareSearchState, useElementBoundingBox, useLockScroll,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('react-redux');
|
||||
jest.mock('react-router-dom');
|
||||
jest.mock('../data/thunks');
|
||||
|
||||
describe('CoursewareSearch Hooks', () => {
|
||||
const courses = {
|
||||
123: { enabled: true },
|
||||
456: { enabled: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCoursewareSearchSettings.mockImplementation((courseId) => Promise.resolve(courses[courseId]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchFeatureFlag', () => {
|
||||
const renderTestHook = async (enabled = true) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchFeatureFlag())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return true if feature is enabled', async () => {
|
||||
const hook = await renderTestHook();
|
||||
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if feature is disabled', async () => {
|
||||
const hook = await renderTestHook(false);
|
||||
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchState', () => {
|
||||
const renderTestHook = async ({ enabled, showSearch }) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
const mockedStoreState = { courseHome: { showSearch } };
|
||||
useSelector.mockImplementation(selector => selector(mockedStoreState));
|
||||
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchState())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return show: true if feature is enabled and showSearch is true', async () => {
|
||||
const hook = await renderTestHook({ enabled: true, showSearch: true });
|
||||
|
||||
expect(hook.result.current).toEqual({ show: true });
|
||||
});
|
||||
|
||||
it('should return show: false in any other case', async () => {
|
||||
let hook;
|
||||
|
||||
hook = await renderTestHook({ enabled: true, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: true });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useElementBoundingBox', () => {
|
||||
let getBoundingClientRectSpy;
|
||||
const renderTestHook = async ({ elementId, mockedInfo }) => {
|
||||
getBoundingClientRectSpy = jest.spyOn(document, 'getElementById').mockImplementation(() => (
|
||||
mockedInfo
|
||||
? { getBoundingClientRect: () => ({ ...mockedInfo }) }
|
||||
: undefined
|
||||
));
|
||||
|
||||
let hook;
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useElementBoundingBox(elementId));
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
let addEventListenerSpy;
|
||||
let removeEventListenerSpy;
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
|
||||
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
|
||||
});
|
||||
|
||||
describe('when element is present', () => {
|
||||
const mockedInfo = { top: 128 };
|
||||
|
||||
it('should bind resize and scroll events on mount', async () => {
|
||||
await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should unbindbind resize and scroll events when unmounted', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
hook.unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should return the element bounding box', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
hook.waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
|
||||
it('should call getBoundingClientRect on window resize', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
act(() => {
|
||||
// Trigger the window resize event.
|
||||
global.innerWidth = 500;
|
||||
global.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when element is NOT present', () => {
|
||||
let consoleWarnSpy;
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should log a warning and return undefined', async () => {
|
||||
await renderTestHook({ elementId: 'happiness' });
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith("useElementBoundingBox(): Unable to find element with id='happiness' in the document.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLockScroll', () => {
|
||||
const renderTestHook = () => (
|
||||
renderHook(() => useLockScroll())
|
||||
);
|
||||
|
||||
let windowScrollSpy;
|
||||
let addBodyClassSpy;
|
||||
let removeBodyClassSpy;
|
||||
let hook;
|
||||
|
||||
beforeEach(() => {
|
||||
windowScrollSpy = jest.spyOn(window, 'scrollTo');
|
||||
addBodyClassSpy = jest.spyOn(document.body.classList, 'add');
|
||||
removeBodyClassSpy = jest.spyOn(document.body.classList, 'remove');
|
||||
hook = renderTestHook();
|
||||
});
|
||||
|
||||
it('should perform a scrollTo(0, 0) on mount', () => {
|
||||
expect(windowScrollSpy).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('should append a _search-no-scroll on mount to the document body', () => {
|
||||
expect(addBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
|
||||
it('should remove the _search-no-scroll on unmount', () => {
|
||||
hook.unmount();
|
||||
|
||||
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/course-home/courseware-search/index.js
Normal file
3
src/course-home/courseware-search/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
|
||||
export { default as CoursewareSearch } from './CoursewareSearch';
|
||||
21
src/course-home/courseware-search/messages.js
Normal file
21
src/course-home/courseware-search/messages.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchOpenAction: {
|
||||
id: 'learn.coursewareSerch.openAction',
|
||||
defaultMessage: 'Search within this course',
|
||||
description: 'Aria-label for a button that will pop up Courseware Search.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSerch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSerch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
@@ -9,7 +8,9 @@ Factory.define('courseHomeMetadata')
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
can_load_courseware: false,
|
||||
is_staff: false,
|
||||
can_view_certificate: true,
|
||||
celebrations: null,
|
||||
course_access: {
|
||||
additional_context_user_message: null,
|
||||
developer_message: null,
|
||||
@@ -18,5 +19,104 @@ Factory.define('courseHomeMetadata')
|
||||
user_fragment: null,
|
||||
user_message: null,
|
||||
},
|
||||
number: 'DemoX',
|
||||
original_user_is_staff: false,
|
||||
org: 'edX',
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
});
|
||||
user_timezone: 'UTC',
|
||||
username: 'MockUser',
|
||||
verified_mode: {
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
sku: '8CF08E5',
|
||||
price: 149,
|
||||
currency_symbol: '$',
|
||||
},
|
||||
})
|
||||
.attr('tabs', ['id', 'host'], (id, host) => [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'discussion/forum/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course_wiki',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'progress',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'instructor',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Dates',
|
||||
priority: 5,
|
||||
slug: 'dates',
|
||||
type: 'dates',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'dates',
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -219,5 +219,4 @@ Factory.define('datesTabData')
|
||||
],
|
||||
has_ended: false,
|
||||
learner_is_full_access: true,
|
||||
user_timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ Factory.define('outlineTabData')
|
||||
})
|
||||
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
|
||||
course_date_blocks: dateBlocks,
|
||||
user_timezone: 'UTC',
|
||||
}))
|
||||
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
|
||||
has_visited_course: false,
|
||||
@@ -29,6 +28,7 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
course_access_redirect: false,
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
@@ -36,11 +36,13 @@ Factory.define('outlineTabData')
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
selected_goal: null,
|
||||
weekly_learning_goal_enabled: false,
|
||||
days_per_week: null,
|
||||
subscribed_to_reminders: null,
|
||||
},
|
||||
course_tools: [
|
||||
{
|
||||
@@ -48,11 +50,6 @@ Factory.define('outlineTabData')
|
||||
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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
|
||||
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
|
||||
Factory.define('progressTabData')
|
||||
.attrs({
|
||||
access_expiration: null,
|
||||
end: '3027-03-31T00:00:00Z',
|
||||
certificate_data: {},
|
||||
completion_summary: {
|
||||
@@ -16,6 +17,7 @@ Factory.define('progressTabData')
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
credit_course_requirements: null,
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
|
||||
@@ -5,7 +5,6 @@ Factory.define('upgradeNotificationData')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.option('accessExpiration', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('upsellPageName', 'test')
|
||||
@@ -18,4 +17,9 @@ Factory.define('upgradeNotificationData')
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attrs({
|
||||
accessExpiration: {
|
||||
expiration_date: '1950-07-13T02:04:49.040006Z',
|
||||
},
|
||||
})
|
||||
.attr('timeOffsetMillis', 0);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
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",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -13,15 +15,18 @@ Object {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
@@ -30,8 +35,9 @@ Object {
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
@@ -42,44 +48,49 @@ Object {
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
@@ -293,23 +304,31 @@ Object {
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
"hasEnded": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"learnerIsFullAccess": true,
|
||||
"userTimezone": "America/New_York",
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
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",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -318,15 +337,18 @@ Object {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
@@ -335,8 +357,9 @@ Object {
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
@@ -347,57 +370,61 @@ Object {
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": 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 {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
@@ -407,7 +434,7 @@ Object {
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
@@ -425,7 +452,6 @@ Object {
|
||||
"effortTime": 15,
|
||||
"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",
|
||||
@@ -433,8 +459,11 @@ Object {
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
@@ -442,11 +471,6 @@ Object {
|
||||
"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,
|
||||
@@ -455,8 +479,8 @@ Object {
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
"userTimezone": "UTC",
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
@@ -465,7 +489,7 @@ Object {
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
"hasVisitedCourse": false,
|
||||
@@ -488,14 +512,23 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
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",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -504,15 +537,18 @@ Object {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
@@ -521,8 +557,9 @@ Object {
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
@@ -533,44 +570,50 @@ Object {
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"accessExpiration": null,
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"completeCount": 1,
|
||||
@@ -581,9 +624,9 @@ Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
"visiblePercent": 1,
|
||||
},
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"creditCourseRequirements": null,
|
||||
"end": "3027-03-31T00:00:00Z",
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
@@ -591,7 +634,7 @@ Object {
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": 1,
|
||||
"averageGrade": "1.00",
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
@@ -604,7 +647,7 @@ Object {
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"displayName": "First section",
|
||||
@@ -675,5 +718,12 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -15,7 +15,10 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
averageGrade = points.reduce((a, b) => a + b, 0) / points.length;
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
@@ -87,17 +90,25 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
||||
* @returns {Object} The normalized metadata
|
||||
*/
|
||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
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
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
||||
// If needed, 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,
|
||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,12 +148,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
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,
|
||||
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
|
||||
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
||||
showLink: !!block.lms_web_url,
|
||||
title: block.display_name,
|
||||
};
|
||||
break;
|
||||
@@ -178,11 +186,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
@@ -191,27 +199,29 @@ export async function getCourseHomeCourseMetadata(courseId) {
|
||||
// import './__factories__';
|
||||
export async function getDatesTabData(courseId) {
|
||||
// return camelCaseObject(Factory.build('datesTabData'));
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return {};
|
||||
}
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 401) {
|
||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgressTabData(courseId, targetUserId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/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.
|
||||
@@ -228,16 +238,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
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.
|
||||
@@ -265,7 +265,7 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
@@ -275,6 +275,12 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -296,6 +302,20 @@ export async function getProctoringInfoData(courseId, username) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLiveTabIframe(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
|
||||
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
|
||||
@@ -312,23 +332,24 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
}
|
||||
|
||||
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;
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const requestTime = Date.now();
|
||||
let tabData;
|
||||
try {
|
||||
requestTime = Date.now();
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
responseTime = Date.now();
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 403) {
|
||||
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
||||
// info is included in the course metadata request and will be handled there as long as this call returns
|
||||
// without an error
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const responseTime = Date.now();
|
||||
|
||||
const {
|
||||
data,
|
||||
headers,
|
||||
@@ -342,6 +363,7 @@ export async function getOutlineTabData(courseId) {
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enableProctoredExams = data.enable_proctored_exams;
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const enrollmentMode = data.enrollment_mode;
|
||||
const handoutsHtml = data.handouts_html;
|
||||
@@ -352,7 +374,7 @@ export async function getOutlineTabData(courseId) {
|
||||
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
||||
const userHasPassingGrade = data.user_has_passing_grade;
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
const welcomeMessageHtml = data.welcome_message_html || '';
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
@@ -365,6 +387,7 @@ export async function getOutlineTabData(courseId) {
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
enrollmentMode,
|
||||
enableProctoredExams,
|
||||
handoutsHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
@@ -385,13 +408,22 @@ export async function postCourseDeadlines(courseId, model) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function postCourseGoals(courseId, goalKey) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
|
||||
export async function deprecatedPostCourseGoals(courseId, goalKey) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
||||
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
|
||||
}
|
||||
|
||||
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
||||
return getAuthenticatedHttpClient().post(url.href, {
|
||||
course_id: courseId,
|
||||
days_per_week: daysPerWeek,
|
||||
subscribed_to_reminders: subscribedToReminders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function postDismissWelcomeMessage(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
||||
}
|
||||
|
||||
@@ -407,3 +439,15 @@ export async function executePostFromPostEvent(postData, researchEventData) {
|
||||
research_event_data: researchEventData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsubscribeFromCourseGoal(token) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
|
||||
return getAuthenticatedHttpClient().post(url.href)
|
||||
.then(res => camelCaseObject(res));
|
||||
}
|
||||
|
||||
export async function getCoursewareSearchEnabledFlag(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href);
|
||||
return { enabled: data.enabled || false };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ export {
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
saveCourseGoal,
|
||||
deprecatedSaveCourseGoal,
|
||||
saveWeeklyLearningGoal,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
|
||||
220
src/course-home/data/pact-tests/lmsPact.test.jsx
Normal file
220
src/course-home/data/pact-tests/lmsPact.test.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||
|
||||
import {
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
} from '../api';
|
||||
|
||||
import { initializeMockApp } from '../../../setupTest';
|
||||
import {
|
||||
courseId, dateRegex, opaqueKeysRegex, dateTypeRegex,
|
||||
} from '../../../pacts/constants';
|
||||
|
||||
const {
|
||||
somethingLike: like, term, boolean, string, eachLike,
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
|
||||
dir: path.resolve(process.cwd(), 'src/pacts'),
|
||||
pactfileWriteMode: 'merge',
|
||||
logLevel: 'DEBUG',
|
||||
cors: true,
|
||||
});
|
||||
|
||||
describe('Course Home Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, 'Custom app config for pact tests');
|
||||
});
|
||||
|
||||
describe('When a request to fetch tab is made', () => {
|
||||
it('returns tab data for a course_id', async () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
},
|
||||
course_id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
is_enrolled: boolean(true),
|
||||
is_self_paced: boolean(false),
|
||||
is_staff: boolean(true),
|
||||
number: string('DemoX'),
|
||||
org: string('edX'),
|
||||
original_user_is_staff: boolean(true),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
tab_id: 'courseware',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to fetch dates tab is made', () => {
|
||||
it('returns course date blocks for a course_id', async () => {
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
complete: null,
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learner_has_access: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extra_info: null,
|
||||
first_component_block_id: '',
|
||||
}),
|
||||
has_ended: boolean(false),
|
||||
learner_is_full_access: boolean(true),
|
||||
user_timezone: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
datesBannerInfo: {
|
||||
missedDeadlines: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
courseDateBlocks: [
|
||||
{
|
||||
assignmentType: null,
|
||||
complete: null,
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
dateType: 'verified-upgrade-deadline',
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learnerHasAccess: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
linkText: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
const response = getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,21 @@ 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}`;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
const courseHomeAccessDeniedMetadata = Factory.build(
|
||||
'courseHomeMetadata',
|
||||
{
|
||||
id: courseId,
|
||||
course_access: {
|
||||
has_access: false,
|
||||
error_code: 'bad codes',
|
||||
additional_context_user_message: 'your Codes Are BAD',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -31,7 +43,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test fetchDatesTab', () => {
|
||||
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
|
||||
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates`;
|
||||
|
||||
it('Should fail to fetch if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
@@ -55,16 +67,40 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state).toMatchSnapshot({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 401 || errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test fetchOutlineTab', () => {
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
axiosMock.onGet(outlineUrl).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
@@ -75,8 +111,6 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
||||
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
@@ -84,12 +118,35 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state).toMatchSnapshot({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test fetchProgressTab', () => {
|
||||
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress`;
|
||||
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
@@ -113,7 +170,14 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
expect(state).toMatchSnapshot({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
@@ -129,14 +193,27 @@ describe('Data layer integration tests', () => {
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.targetUserId).toEqual(2);
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
async (errorStatus) => {
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}`;
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('denied');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
it('Should save course goal', async () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
axiosMock.onPost(goalUrl).reply(200, {});
|
||||
|
||||
await thunks.saveCourseGoal(courseId, 'unsure');
|
||||
await thunks.deprecatedSaveCourseGoal(courseId, 'unsure');
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
@@ -164,7 +241,7 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
describe('Test dismissWelcomeMessage', () => {
|
||||
it('Should dismiss welcome message', async () => {
|
||||
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
|
||||
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
|
||||
axiosMock.onPost(dismissUrl).reply(201);
|
||||
|
||||
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
|
||||
@@ -173,4 +250,36 @@ describe('Data layer integration tests', () => {
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchCoursewareSearchSettings', () => {
|
||||
it('Should return enabled as true when enabled', async () => {
|
||||
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
|
||||
axiosMock.onGet(apiUrl).reply(200, { enabled: true });
|
||||
|
||||
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
|
||||
expect(enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('Should return enabled as false when disabled', async () => {
|
||||
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
|
||||
axiosMock.onGet(apiUrl).reply(200, { enabled: false });
|
||||
|
||||
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Should return enabled as false on error', async () => {
|
||||
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
|
||||
axiosMock.onGet(apiUrl).networkError();
|
||||
|
||||
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,16 @@ const slice = createSlice({
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
proctoringPanelStatus: 'loading',
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
state.proctoringPanelStatus = LOADED;
|
||||
},
|
||||
fetchTabDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = DENIED;
|
||||
@@ -43,15 +48,20 @@ const slice = createSlice({
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchProctoringInfoResolved,
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
postCourseGoals,
|
||||
deprecatedPostCourseGoals,
|
||||
postWeeklyLearningGoal,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
getCoursewareSearchEnabledFlag,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
@@ -31,46 +34,38 @@ const eventTypes = {
|
||||
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) {
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadata,
|
||||
},
|
||||
}));
|
||||
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
|
||||
if (tabDataResult) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult.value,
|
||||
...tabDataResult,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
} else if (tabDataResult || !getTabData) {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
targetUserId,
|
||||
}));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
logError(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +81,14 @@ export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function fetchLiveTab(courseId) {
|
||||
return fetchTab(courseId, 'live', getLiveTabIframe);
|
||||
}
|
||||
|
||||
export function fetchDiscussionTab(courseId) {
|
||||
return fetchTab(courseId, 'discussion');
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
@@ -109,8 +112,12 @@ export function resetDeadlines(courseId, model, getTabData) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveCourseGoal(courseId, goalKey) {
|
||||
return postCourseGoals(courseId, goalKey);
|
||||
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
|
||||
return deprecatedPostCourseGoals(courseId, goalKey);
|
||||
}
|
||||
|
||||
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
||||
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
|
||||
}
|
||||
|
||||
export function processEvent(eventData, getTabData) {
|
||||
@@ -133,3 +140,12 @@ export function processEvent(eventData, getTabData) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCoursewareSearchSettings(courseId) {
|
||||
try {
|
||||
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
|
||||
return { enabled };
|
||||
} catch (e) {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ 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 DatesTab = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -30,9 +28,6 @@ function DatesTab({ intl }) {
|
||||
courseDateBlocks,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const mmp2p = initDatesMMP2P(courseId);
|
||||
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
@@ -51,8 +46,7 @@ function DatesTab({ intl }) {
|
||||
<div role="heading" aria-level="1" className="h2 my-3">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
|
||||
{isSelfPaced && hasDeadlines && (
|
||||
<>
|
||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||
<SuggestedScheduleHeader />
|
||||
@@ -60,10 +54,10 @@ function DatesTab({ intl }) {
|
||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||
</>
|
||||
)}
|
||||
<Timeline mmp2p={mmp2p} />
|
||||
<Timeline />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DatesTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
@@ -32,26 +32,31 @@ describe('DatesTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/dates"
|
||||
element={(
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
let courseMetadata = Factory.build('courseHomeMetadata');
|
||||
let courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
|
||||
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}`;
|
||||
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
@@ -140,7 +145,7 @@ describe('DatesTab', () => {
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,12 +346,12 @@ describe('DatesTab', () => {
|
||||
|
||||
it('redirects to the home page when unauthenticated', async () => {
|
||||
await renderDenied('authentication_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
|
||||
it('redirects to the home page when unenrolled', async () => {
|
||||
await renderDenied('enrollment_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,30 +4,37 @@ const messages = defineMessages({
|
||||
completed: {
|
||||
id: 'learning.dates.badge.completed',
|
||||
defaultMessage: 'Completed',
|
||||
description: 'shown as label for the assignments which learner has completed.',
|
||||
},
|
||||
dueNext: {
|
||||
id: 'learning.dates.badge.dueNext',
|
||||
defaultMessage: 'Due next',
|
||||
description: 'Shown as label for the assignment which date is in the future',
|
||||
},
|
||||
pastDue: {
|
||||
id: 'learning.dates.badge.pastDue',
|
||||
defaultMessage: 'Past due',
|
||||
description: 'Shown as label for the assignments which deadline has passed',
|
||||
},
|
||||
title: {
|
||||
id: 'learning.dates.title',
|
||||
defaultMessage: 'Important dates',
|
||||
description: 'The title of dates tab (course timeline).',
|
||||
},
|
||||
today: {
|
||||
id: 'learning.dates.badge.today',
|
||||
defaultMessage: 'Today',
|
||||
description: 'Label used when the scheduled date for the assignment matches the current day',
|
||||
},
|
||||
unreleased: {
|
||||
id: 'learning.dates.badge.unreleased',
|
||||
defaultMessage: 'Not yet released',
|
||||
description: 'Shown as label for assignments which date is unknown yet',
|
||||
},
|
||||
verifiedOnly: {
|
||||
id: 'learning.dates.badge.verifiedOnly',
|
||||
defaultMessage: 'Verified only',
|
||||
description: 'Shown as label for assignments which learner has no access to.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,31 +17,24 @@ import { useModel } from '../../../generic/model-store';
|
||||
import { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from '../utils';
|
||||
|
||||
function Day({
|
||||
const Day = ({
|
||||
date,
|
||||
first,
|
||||
intl,
|
||||
items,
|
||||
last,
|
||||
/** [MM-P2P] Example */
|
||||
mmp2p,
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('dates', courseId);
|
||||
} = useModel('courseHomeMeta', 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 */}
|
||||
@@ -57,8 +50,7 @@ function Day({
|
||||
<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}
|
||||
value={date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
@@ -68,10 +60,7 @@ function Day({
|
||||
{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 { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
|
||||
|
||||
const showDueDateTime = item.dateType === 'assignment-due-date';
|
||||
const showLink = item.link && isLearnerAssignment(item);
|
||||
@@ -107,22 +96,14 @@ function Day({
|
||||
</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>)}
|
||||
{item.description && <div className="small mb-2">{item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Day.propTypes = {
|
||||
date: PropTypes.objectOf(Date).isRequired,
|
||||
@@ -138,25 +119,11 @@ Day.propTypes = {
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from 'react';
|
||||
/** [MM-P2P] Experiment (import) */
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
@@ -8,8 +6,7 @@ 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 Timeline = () => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -66,17 +63,10 @@ export default function Timeline({ mmp2p }) {
|
||||
return (
|
||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
|
||||
<Day key={groupedDate.date} {...groupedDate} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
Timeline.propTypes = {
|
||||
mmp2p: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
Timeline.defaultProps = {
|
||||
mmp2p: {},
|
||||
};
|
||||
export default Timeline;
|
||||
|
||||
35
src/course-home/discussion-tab/DiscussionTab.jsx
Normal file
35
src/course-home/discussion-tab/DiscussionTab.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams, generatePath, useNavigate } from 'react-router-dom';
|
||||
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
|
||||
|
||||
const DiscussionTab = () => {
|
||||
const { courseId } = useSelector(state => state.courseHome);
|
||||
const { path } = useParams();
|
||||
const [originalPath] = useState(path);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, iFrameHeight] = useIFrameHeight();
|
||||
useIFramePluginEvents({
|
||||
'discussions.navigate': (payload) => {
|
||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||
navigate(`${basePath}/${payload.path}`);
|
||||
},
|
||||
});
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
||||
return (
|
||||
<iframe
|
||||
src={discussionsUrl}
|
||||
className="d-flex w-100 border-0"
|
||||
height={iFrameHeight}
|
||||
style={{ minHeight: '60rem' }}
|
||||
title="discussion"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionTab.propTypes = {};
|
||||
|
||||
export default injectIntl(DiscussionTab);
|
||||
66
src/course-home/discussion-tab/DiscussionTab.test.jsx
Normal file
66
src/course-home/discussion-tab/DiscussionTab.test.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
import {
|
||||
initializeMockApp, messageEvent, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { TabContainer } from '../../tab-page';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
import { fetchDiscussionTab } from '../data/thunks';
|
||||
import DiscussionTab from './DiscussionTab';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('DiscussionTab', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/discussion"
|
||||
element={(
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
|
||||
const { id: courseId } = courseMetadata;
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
|
||||
it('resizes when it gets a size hint from iframe', async () => {
|
||||
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
|
||||
await waitFor(() => expect(screen.getByTitle('discussion'))
|
||||
.toHaveAttribute('height', String(1234)));
|
||||
});
|
||||
});
|
||||
58
src/course-home/goal-unsubscribe/GoalUnsubscribe.jsx
Normal file
58
src/course-home/goal-unsubscribe/GoalUnsubscribe.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import PageLoading from '../../generic/PageLoading';
|
||||
import { unsubscribeFromCourseGoal } from '../data/api';
|
||||
|
||||
import messages from './messages';
|
||||
import ResultPage from './ResultPage';
|
||||
|
||||
const GoalUnsubscribe = ({ intl }) => {
|
||||
const { token } = useParams();
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [data, setData] = useState({});
|
||||
|
||||
// We don't need to bother with redux for this simple page. We're not sharing state with other pages at all.
|
||||
useEffect(() => {
|
||||
unsubscribeFromCourseGoal(token)
|
||||
.then(
|
||||
(result) => {
|
||||
setIsLoading(false);
|
||||
setData(result.data);
|
||||
},
|
||||
() => {
|
||||
setIsLoading(false);
|
||||
setError(true);
|
||||
},
|
||||
);
|
||||
// We unfortunately have no information about the user, course, org, or really anything
|
||||
// as visiting this page is allowed to be done anonymously and without the context of the course.
|
||||
// The token can be used to connect a user and course, it will just require some post-processing
|
||||
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // deps=[] to only run once
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showUserDropdown={false} />
|
||||
<main id="main-content" className="container my-5 text-center">
|
||||
{isLoading && (
|
||||
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
|
||||
)}
|
||||
{!isLoading && (
|
||||
<ResultPage error={error} courseTitle={data.courseTitle} />
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
GoalUnsubscribe.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GoalUnsubscribe);
|
||||
67
src/course-home/goal-unsubscribe/GoalUnsubscribe.test.jsx
Normal file
67
src/course-home/goal-unsubscribe/GoalUnsubscribe.test.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import GoalUnsubscribe from './GoalUnsubscribe';
|
||||
import { act, initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('GoalUnsubscribe', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
let component;
|
||||
const unsubscribeUrl = `${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/TOKEN`;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
|
||||
<Routes>
|
||||
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
it('starts with a spinner', () => {
|
||||
render(component);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads a real token', async () => {
|
||||
const response = { course_title: 'My Sample Course' };
|
||||
axiosMock.onPost(unsubscribeUrl).reply(200, response);
|
||||
await act(async () => render(component));
|
||||
|
||||
expect(screen.getByText('You’ve unsubscribed from goal reminders')).toBeInTheDocument();
|
||||
expect(screen.getByText(/your goal for My Sample Course/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Go to dashboard' }))
|
||||
.toHaveAttribute('href', 'http://localhost:18000/dashboard');
|
||||
});
|
||||
|
||||
it('loads a bad token with an error page', async () => {
|
||||
axiosMock.onPost(unsubscribeUrl).reply(404, {});
|
||||
await act(async () => render(component));
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Go to dashboard' }))
|
||||
.toHaveAttribute('href', 'http://localhost:18000/dashboard');
|
||||
expect(screen.getByRole('link', { name: 'contact support' }))
|
||||
.toHaveAttribute('href', 'http://localhost:18000/contact');
|
||||
});
|
||||
});
|
||||
60
src/course-home/goal-unsubscribe/ResultPage.jsx
Normal file
60
src/course-home/goal-unsubscribe/ResultPage.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Hyperlink } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
|
||||
|
||||
const ResultPage = ({ courseTitle, error, intl }) => {
|
||||
const errorDescription = (
|
||||
<FormattedMessage
|
||||
id="learning.goals.unsubscribe.errorDescription"
|
||||
defaultMessage="We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help."
|
||||
values={{
|
||||
contactSupport: (
|
||||
<Hyperlink
|
||||
className="text-reset"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().CONTACT_URL}`}
|
||||
>
|
||||
{intl.formatMessage(messages.contactSupport)}
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = error
|
||||
? intl.formatMessage(messages.errorHeader)
|
||||
: intl.formatMessage(messages.header);
|
||||
const description = error
|
||||
? errorDescription
|
||||
: intl.formatMessage(messages.description, { courseTitle });
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnsubscribeIcon className="text-primary" alt="" />
|
||||
<div role="heading" aria-level="1" className="h2">{header}</div>
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-xl-7 col-12 p-0">{description}</div>
|
||||
</div>
|
||||
<Button variant="brand" href={`${getConfig().LMS_BASE_URL}/dashboard`} className="mt-4">
|
||||
{intl.formatMessage(messages.goToDashboard)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ResultPage.defaultProps = {
|
||||
courseTitle: null,
|
||||
error: false,
|
||||
};
|
||||
|
||||
ResultPage.propTypes = {
|
||||
courseTitle: PropTypes.string,
|
||||
error: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ResultPage);
|
||||
3
src/course-home/goal-unsubscribe/index.jsx
Normal file
3
src/course-home/goal-unsubscribe/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import GoalUnsubscribe from './GoalUnsubscribe';
|
||||
|
||||
export default GoalUnsubscribe;
|
||||
36
src/course-home/goal-unsubscribe/messages.js
Normal file
36
src/course-home/goal-unsubscribe/messages.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
contactSupport: {
|
||||
id: 'learning.goals.unsubscribe.contact',
|
||||
defaultMessage: 'contact support',
|
||||
description: 'Its shown as a suggestion or recommendation for learner when their unsubscribing request has failed',
|
||||
},
|
||||
description: {
|
||||
id: 'learning.goals.unsubscribe.description',
|
||||
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.',
|
||||
description: 'It describes the consequences to learner when they unsubscribe of goal reminder service',
|
||||
},
|
||||
errorHeader: {
|
||||
id: 'learning.goals.unsubscribe.errorHeader',
|
||||
defaultMessage: 'Something went wrong',
|
||||
description: 'It indicate that the unsubscribing request has failed',
|
||||
},
|
||||
goToDashboard: {
|
||||
id: 'learning.goals.unsubscribe.goToDashboard',
|
||||
defaultMessage: 'Go to dashboard',
|
||||
description: 'Anchor text for button that redirects to dashboard page',
|
||||
},
|
||||
header: {
|
||||
id: 'learning.goals.unsubscribe.header',
|
||||
defaultMessage: 'You’ve unsubscribed from goal reminders',
|
||||
description: 'It indicate that the unsubscribing request was successful',
|
||||
},
|
||||
loading: {
|
||||
id: 'learning.goals.unsubscribe.loading',
|
||||
defaultMessage: 'Unsubscribing…',
|
||||
description: 'Message shown when the unsubscribing request is processing',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
5
src/course-home/goal-unsubscribe/unsubscribe.svg
Normal file
5
src/course-home/goal-unsubscribe/unsubscribe.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="167" height="153" viewBox="0 0 167 153" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M140.25 25.5H12.75V127.5H140.25V25.5ZM127.5 46L76.5 77.875L25.5 46V38.25L76.5 70.125L127.5 38.25V46Z" fill="currentColor"/>
|
||||
<circle cx="134" cy="39" r="33" transform="rotate(-90 134 39)" fill="white"/>
|
||||
<path d="M134 11C118.544 11 106 23.544 106 39C106 54.456 118.544 67 134 67C149.456 67 162 54.456 162 39C162 23.544 149.456 11 134 11ZM134 61.4C121.624 61.4 111.6 51.376 111.6 39C111.6 33.82 113.364 29.06 116.332 25.28L147.72 56.668C143.94 59.636 139.18 61.4 134 61.4ZM151.668 52.72L120.28 21.332C124.06 18.364 128.82 16.6 134 16.6C146.376 16.6 156.4 26.624 156.4 39C156.4 44.18 154.636 48.94 151.668 52.72Z" fill="#D23228"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 743 B |
22
src/course-home/live-tab/LiveTab.jsx
Normal file
22
src/course-home/live-tab/LiveTab.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const LiveTab = () => {
|
||||
const { courseId } = useSelector(state => state.courseHome);
|
||||
const liveModel = useSelector(state => state.models.live);
|
||||
useEffect(() => {
|
||||
const iframe = document.getElementById('lti-tab-embed');
|
||||
if (iframe) {
|
||||
iframe.className += ' vh-100 w-100 border-0';
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
id="live_tab"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveTab;
|
||||
@@ -9,12 +9,10 @@ import { useModel } from '../../generic/model-store';
|
||||
import { isLearnerAssignment } from '../dates-tab/utils';
|
||||
import './DateSummary.scss';
|
||||
|
||||
export default function DateSummary({
|
||||
const DateSummary = ({
|
||||
dateBlock,
|
||||
userTimezone,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -25,9 +23,6 @@ export default function DateSummary({
|
||||
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,
|
||||
@@ -40,13 +35,12 @@ export default function DateSummary({
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="container p-0 mb-3 small text-dark-500">
|
||||
<li className="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}
|
||||
value={dateBlock.date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
@@ -55,48 +49,33 @@ export default function DateSummary({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/** [MM-P2P] Experiment (conditional) */}
|
||||
{ showMMP2P ? (
|
||||
<div className="row ml-4 pr-2">
|
||||
<div className="date-summary-text">
|
||||
<div className="row ml-4 pr-2">
|
||||
<div className="date-summary-text">
|
||||
{linkedTitle && (
|
||||
<div className="font-weight-bold mt-2">
|
||||
Last chance to upgrade
|
||||
<a href={dateBlock.link}>{dateBlock.title}</a>
|
||||
</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>
|
||||
{!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({
|
||||
@@ -109,22 +88,10 @@ DateSummary.propTypes = {
|
||||
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: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default DateSummary;
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export default function LmsHtmlFragment({
|
||||
const 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">
|
||||
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
||||
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/static/LmsHtmlFragment.css">
|
||||
</head>
|
||||
<body class="${className}">${html}</body>
|
||||
<script>
|
||||
const resizer = new ResizeObserver(() => {
|
||||
window.parent.postMessage({type: 'lmshtmlfragment.resize'}, '*');
|
||||
});
|
||||
resizer.observe(document.body);
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const iframe = useRef(null);
|
||||
function handleLoad() {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
function resetIframeHeight() {
|
||||
if (iframe?.current?.contentWindow?.document?.body) {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function receiveMessage(event) {
|
||||
const { type } = event.data;
|
||||
if (type === 'lmshtmlfragment.resize') {
|
||||
resetIframeHeight();
|
||||
}
|
||||
}
|
||||
global.addEventListener('message', receiveMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-100 border-0"
|
||||
onLoad={handleLoad}
|
||||
onLoad={resetIframeHeight}
|
||||
ref={iframe}
|
||||
referrerPolicy="origin"
|
||||
scrolling="no"
|
||||
@@ -37,7 +55,7 @@ export default function LmsHtmlFragment({
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
LmsHtmlFragment.defaultProps = {
|
||||
className: '',
|
||||
@@ -48,3 +66,5 @@ LmsHtmlFragment.propTypes = {
|
||||
html: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default LmsHtmlFragment;
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Toast } from '@edx/paragon';
|
||||
import { Button } 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 StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard';
|
||||
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
|
||||
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 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';
|
||||
@@ -29,19 +28,17 @@ 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 OutlineTab = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
proctoringPanelStatus,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
@@ -51,42 +48,32 @@ function OutlineTab({ intl }) {
|
||||
sections,
|
||||
},
|
||||
courseGoals: {
|
||||
goalOptions,
|
||||
selectedGoal,
|
||||
weeklyLearningGoalEnabled,
|
||||
} = {},
|
||||
datesBannerInfo,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
enableProctoredExams,
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
|
||||
const [goalToastHeader, setGoalToastHeader] = useState('');
|
||||
const {
|
||||
marketingUrl,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
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);
|
||||
@@ -107,31 +94,42 @@ function OutlineTab({ intl }) {
|
||||
});
|
||||
};
|
||||
|
||||
/** [[MM-P2P] Experiment */
|
||||
const MMP2P = initHomeMMP2P(courseId);
|
||||
const isEnterpriseUser = () => {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
const userRoleNames = authenticatedUser ? authenticatedUser.roles.map(role => role.split(':')[0]) : [];
|
||||
|
||||
return userRoleNames.includes('enterprise_learner');
|
||||
};
|
||||
|
||||
/** show post enrolment survey to only B2C learners */
|
||||
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const currentParams = new URLSearchParams(location.search);
|
||||
const startCourse = currentParams.get('start_course');
|
||||
if (startCourse === '1') {
|
||||
sendTrackEvent('enrollment.email.clicked.startcourse', {});
|
||||
|
||||
// Deleting the course_start query param as it only needs to be set once
|
||||
// whenever passed in query params.
|
||||
currentParams.delete('start_course');
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${currentParams.toString()}`,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
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 data-learner-type={learnerType} 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">
|
||||
@@ -143,47 +141,34 @@ function OutlineTab({ intl }) {
|
||||
/>
|
||||
</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 && (
|
||||
<AlertList
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
{isSelfPaced && hasDeadlines && (
|
||||
<>
|
||||
<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); }}
|
||||
/>
|
||||
)}
|
||||
<StartOrResumeCourseCard />
|
||||
<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">
|
||||
<div className="col-12 col-md-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">
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
@@ -199,53 +184,37 @@ function OutlineTab({ intl }) {
|
||||
</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); }}
|
||||
<ProctoringInfoPanel />
|
||||
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
|
||||
disabled to avoid components bouncing around too much as screen is rendered */ }
|
||||
{(!enableProctoredExams || proctoringPanelStatus === 'loaded') && weeklyLearningGoalEnabled && (
|
||||
<WeeklyLearningGoalCard
|
||||
daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null}
|
||||
subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false}
|
||||
/>
|
||||
)}
|
||||
<CourseTools
|
||||
courseId={courseId}
|
||||
/>
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{ MMP2P.state.isEnabled
|
||||
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||
: (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
courseId={courseId}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
<CourseHandouts
|
||||
<CourseTools />
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
OutlineTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
@@ -6,6 +10,7 @@ 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 messages from './messages';
|
||||
|
||||
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
|
||||
import {
|
||||
@@ -16,6 +21,7 @@ import * as thunks from '../data/thunks';
|
||||
import initializeStore from '../../store';
|
||||
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
|
||||
import OutlineTab from './OutlineTab';
|
||||
import LoadedTabPage from '../../tab-page/LoadedTabPage';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
@@ -23,20 +29,21 @@ 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}`;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/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?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata');
|
||||
const defaultTabData = Factory.build('outlineTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
@@ -45,9 +52,14 @@ describe('Outline Tab', () => {
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
async function fetchAndRender(path = '') {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(<OutlineTab />, { store }));
|
||||
await act(async () => render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<OutlineTab />
|
||||
</MemoryRouter>,
|
||||
{ store },
|
||||
));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -57,6 +69,7 @@ describe('Outline Tab', () => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onPost(enrollmentUrl).reply(200, {});
|
||||
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'created',
|
||||
@@ -70,7 +83,7 @@ describe('Outline Tab', () => {
|
||||
describe('Course Outline', () => {
|
||||
it('displays link to start course', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays link to resume course', async () => {
|
||||
@@ -106,11 +119,11 @@ describe('Outline Tab', () => {
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
@@ -131,25 +144,8 @@ describe('Outline Tab', () => {
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to legacy courseware', async () => {
|
||||
it('SequenceLink displays link', 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 },
|
||||
});
|
||||
@@ -325,88 +321,164 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
describe('Start or Resume Course Card', () => {
|
||||
it('renders startOrResumeCourseCard', 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();
|
||||
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weekly Learning Goal', () => {
|
||||
it('does not post goals while masquerading', async () => {
|
||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
|
||||
|
||||
await fetchAndRender();
|
||||
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
|
||||
fireEvent.click(button);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
describe('goal is not set', () => {
|
||||
it('post goal via query param', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
await fetchAndRender('http://localhost/?weekly_goal=3');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.setgoal', {});
|
||||
});
|
||||
|
||||
it('emit start course event via query param', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender('http://localhost/?start_course=1');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.startcourse', {});
|
||||
});
|
||||
|
||||
describe('weekly learning goal is not set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
goal_options: goalOptions,
|
||||
selected_goal: null,
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
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 weekly learning goal card', async () => {
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).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('disables the subscribe button if no goal is set', async () => {
|
||||
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
it.each([
|
||||
{ level: 'Casual', days: 1 },
|
||||
{ level: 'Regular', days: 3 },
|
||||
{ level: 'Intense', days: 5 },
|
||||
])('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
|
||||
// click on Casual goal
|
||||
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
|
||||
fireEvent.click(button);
|
||||
// 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"}`);
|
||||
// subscribe is turned on automatically
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
|
||||
// verify that the additional info about subscriptions shows up
|
||||
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows and hides subscribe to reminders additional text', async () => {
|
||||
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
|
||||
fireEvent.click(button);
|
||||
// Verify the request was made
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
|
||||
// subscribe is turned on automatically
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":3,"subscribed_to_reminders":true}`);
|
||||
// verify that the additional info about subscriptions shows up
|
||||
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
|
||||
|
||||
// Click on subscribe to reminders toggle
|
||||
const subscriptionSwitch = await screen.getByRole('switch', { name: messages.setGoalReminder.defaultMessage });
|
||||
expect(subscriptionSwitch).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(subscriptionSwitch);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[1].url).toMatch(goalUrl);
|
||||
expect(axiosMock.history.post[1].data)
|
||||
.toMatch(`{"course_id":"${courseId}","days_per_week":3,"subscribed_to_reminders":false}`);
|
||||
});
|
||||
|
||||
// verify that the additional info about subscriptions gets hidden
|
||||
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('has button for weekly learning goal selected', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
selected_goal: {
|
||||
subscribed_to_reminders: true,
|
||||
days_per_week: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const button = await screen.queryByTestId('weekly-learning-goal-input-Regular');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('flag-button-selected');
|
||||
});
|
||||
|
||||
it('renders weekly learning goal card if ProctoringInfoPanel is not shown', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(404);
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders weekly learning goal card if ProctoringInfoPanel is not enabled', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
enableProctoredExams: false,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders weekly learning goal card if ProctoringInfoPanel is enabled', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
enableProctoredExams: true,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -437,35 +509,6 @@ describe('Outline Tab', () => {
|
||||
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', () => {
|
||||
@@ -521,32 +564,35 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
describe('Access Expiration Alert', () => {
|
||||
it('has special masquerade text', async () => {
|
||||
it('renders page banner on masquerade', async () => {
|
||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||
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();
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
|
||||
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
|
||||
expect(instructorToolbar).toBeInTheDocument();
|
||||
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not have special masquerade text', async () => {
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||
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();
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
|
||||
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
|
||||
expect(instructorToolbar).toBeInTheDocument();
|
||||
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -555,16 +601,7 @@ describe('Outline Tab', () => {
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
setMetadata({ is_enrolled: true, start: '2999-01-01T00:00:00Z' });
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('Course starts', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
|
||||
@@ -573,16 +610,7 @@ describe('Outline Tab', () => {
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
setMetadata({ is_enrolled: true, start: startDate });
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('Course starts', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
|
||||
@@ -640,7 +668,6 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: tomorrow.toISOString(),
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -657,7 +684,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', async () => {
|
||||
const now = new Date();
|
||||
@@ -668,7 +695,6 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -691,7 +717,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
|
||||
});
|
||||
it('renders non passing grade', async () => {
|
||||
const now = new Date();
|
||||
@@ -724,8 +750,8 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
screen.getAllByText('You are not eligible for a certificate');
|
||||
expect(screen.queryByText('You are not eligible for a certificate')).toBeInTheDocument();
|
||||
screen.getAllByText('You are not yet eligible for a certificate');
|
||||
expect(screen.queryByText('You are not yet eligible for a certificate')).toBeInTheDocument();
|
||||
});
|
||||
it('tracks request cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
@@ -737,7 +763,6 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -764,57 +789,16 @@ describe('Outline Tab', () => {
|
||||
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',
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
courserun_key: courseId,
|
||||
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();
|
||||
@@ -825,7 +809,6 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -852,12 +835,14 @@ describe('Outline Tab', () => {
|
||||
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',
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
'edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -897,7 +882,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -913,7 +898,6 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: 'certificate/testuuid',
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -939,7 +923,6 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -956,33 +939,6 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -1065,6 +1021,22 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
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: 'verified',
|
||||
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. 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('displays expiration warning for other course', 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);
|
||||
@@ -1076,7 +1048,23 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course. 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('Your onboarding profile has been approved. 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('displays expired', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message appears after expiration, set the date 10 days in the past
|
||||
expirationDate.setTime(expirationDate.getTime() - 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
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 status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1097,6 +1085,7 @@ describe('Outline Tab', () => {
|
||||
|
||||
it('does not appear for 404', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(404);
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1210,7 +1199,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accont Activation Alert', () => {
|
||||
describe('Account Activation Alert', () => {
|
||||
beforeEach(() => {
|
||||
const intersectionObserverMock = () => ({
|
||||
observe: () => null,
|
||||
@@ -1238,7 +1227,7 @@ describe('Outline Tab', () => {
|
||||
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 () => {
|
||||
it('sends account activation email on clicking the re-send 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(); });
|
||||
|
||||
@@ -12,13 +12,13 @@ import { useModel } from '../../generic/model-store';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
|
||||
function Section({
|
||||
const Section = ({
|
||||
courseId,
|
||||
defaultOpen,
|
||||
expand,
|
||||
intl,
|
||||
section,
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
@@ -38,6 +38,7 @@ function Section({
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sectionTitle = (
|
||||
@@ -109,7 +110,7 @@ function Section({
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -17,40 +16,73 @@ import EffortEstimate from '../../shared/effort-estimate';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function SequenceLink({
|
||||
const SequenceLink = ({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
legacyWebUrl,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
datesWidget: {
|
||||
userTimezone,
|
||||
},
|
||||
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 coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
const dueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-set"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const noDueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-not-set"
|
||||
defaultMessage="{description}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
@@ -82,35 +114,15 @@ function SequenceLink({
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
{due && (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{due ? dueDateMessage : noDueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const CERT_STATUS_TYPE = {
|
||||
UNVERIFIED: 'unverified',
|
||||
};
|
||||
|
||||
function CertificateStatusAlert({ intl, payload }) {
|
||||
const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
@@ -33,7 +33,6 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
@@ -66,8 +65,8 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
alertProps.body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
|
||||
id="learning.outline.alert.cert.earnedNotAvailable"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
@@ -79,11 +78,7 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
);
|
||||
} 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.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = certURL;
|
||||
alertProps.buttonAction = () => {
|
||||
@@ -194,7 +189,7 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
)}
|
||||
</AlertWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CertificateStatusAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -204,7 +199,6 @@ CertificateStatusAlert.propTypes = {
|
||||
courseEndDate: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
certURL: PropTypes.string,
|
||||
isWebCert: PropTypes.bool,
|
||||
userTimezone: PropTypes.string,
|
||||
org: PropTypes.string,
|
||||
notPassingCourseEnded: PropTypes.bool,
|
||||
|
||||
@@ -39,11 +39,11 @@ function useCertificateStatusAlert(courseId) {
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
certData,
|
||||
hasEnded,
|
||||
userHasPassingGrade,
|
||||
userTimezone,
|
||||
enrollmentMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
@@ -51,10 +51,8 @@ function useCertificateStatusAlert(courseId) {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isWebCert = downloadUrl === null;
|
||||
const isVerifiedEnrollmentMode = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
@@ -63,9 +61,6 @@ function useCertificateStatusAlert(courseId) {
|
||||
let certURL = '';
|
||||
if (certWebViewUrl) {
|
||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
} else if (downloadUrl) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
@@ -80,22 +75,22 @@ function useCertificateStatusAlert(courseId) {
|
||||
&& hasEnded
|
||||
&& !userHasPassingGrade
|
||||
);
|
||||
const payload = {
|
||||
const payload = useMemo(() => ({
|
||||
certificateAvailableDate,
|
||||
certURL,
|
||||
certStatus,
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
};
|
||||
}), [certStatus, certURL, certificateAvailableDate, courseId,
|
||||
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);
|
||||
|
||||
useAlert(isVisible || notPassingCourseEnded, {
|
||||
code: 'clientCertificateStatusAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
payload,
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user