Compare commits
882 Commits
djoy/id_re
...
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 | ||
|
|
71db431b97 | ||
|
|
b741525bfc | ||
|
|
0394118608 | ||
|
|
c6df8cdbb5 | ||
|
|
1fbd9d4645 | ||
|
|
42a3f6b244 | ||
|
|
6ca4c99c0d | ||
|
|
5deac01615 | ||
|
|
1160353ab9 | ||
|
|
ca8cfda9b9 | ||
|
|
3d9cb20e33 | ||
|
|
37f32fddf2 | ||
|
|
b5689a7997 | ||
|
|
c88ea31c20 | ||
|
|
9de77c282d | ||
|
|
1b995d2510 | ||
|
|
66300caf30 | ||
|
|
29391f7741 | ||
|
|
15782609c3 | ||
|
|
f2fc950678 | ||
|
|
42445d884f | ||
|
|
a5ea7431fc | ||
|
|
df29cd0f9a | ||
|
|
4898487a82 | ||
|
|
6588153e4c | ||
|
|
a568c5f2fc | ||
|
|
054afc0475 | ||
|
|
58e8de2c22 | ||
|
|
2b00cecd19 | ||
|
|
58f1634c63 | ||
|
|
2092a5d8d1 | ||
|
|
1308d1e90b | ||
|
|
3b4dcfefaf | ||
|
|
d4a4cd24ec | ||
|
|
149ca245fd | ||
|
|
56ea6d46d4 | ||
|
|
d12e93d80a | ||
|
|
63c86701de | ||
|
|
b99910357b | ||
|
|
b4bedfe3f0 | ||
|
|
8c41e182a2 | ||
|
|
fae2396977 | ||
|
|
276f2a516a | ||
|
|
01ba277425 | ||
|
|
64f374855b | ||
|
|
a8348e1568 | ||
|
|
6a3ad1d659 | ||
|
|
2f0933be6e | ||
|
|
4be3b8a56f | ||
|
|
2075a0b3dd | ||
|
|
52750ef769 | ||
|
|
e2b00d6684 | ||
|
|
6003865840 | ||
|
|
30a487ec13 | ||
|
|
c667e29492 | ||
|
|
be4375dd7c | ||
|
|
a4d651a77a | ||
|
|
3703e8d81d | ||
|
|
c5e480456a | ||
|
|
d57dd66dd2 | ||
|
|
377f780e85 | ||
|
|
057b431818 | ||
|
|
e4a883e335 | ||
|
|
984926d97c | ||
|
|
9f81897fd2 | ||
|
|
270c177a83 | ||
|
|
915f521976 | ||
|
|
903d8d4cfb | ||
|
|
f2f4f5f3a5 | ||
|
|
28d359e715 | ||
|
|
d93df0e06f | ||
|
|
86a4cf9af7 | ||
|
|
e423dddb03 | ||
|
|
f21dad95b5 | ||
|
|
9978ddf418 | ||
|
|
d2a8d870af | ||
|
|
3ef4daecce | ||
|
|
d2573a16b1 | ||
|
|
e7c0ebdfe3 | ||
|
|
1ad2cf73bf | ||
|
|
d60d782e5a | ||
|
|
83151d291c | ||
|
|
ed55920f99 | ||
|
|
4f1c8a4671 | ||
|
|
0878bf9f13 | ||
|
|
21f3875dae | ||
|
|
d4dc75c5a0 | ||
|
|
8a1151e8c5 | ||
|
|
5e925c93da | ||
|
|
43033ddc91 | ||
|
|
cf08fa5eb9 | ||
|
|
37ce01c00a | ||
|
|
9edac2519a | ||
|
|
f9fbc1eb49 | ||
|
|
5c68c1d554 | ||
|
|
4180a2e7a0 | ||
|
|
554e63d653 | ||
|
|
c9f299eada | ||
|
|
d1bb46eef3 | ||
|
|
492c573f56 | ||
|
|
0c55863a3d | ||
|
|
fbd9d858e4 | ||
|
|
7b0429f472 | ||
|
|
56decd8ed0 | ||
|
|
3155055276 | ||
|
|
1b84930a84 | ||
|
|
99185a9b8a | ||
|
|
e9c3a6bc5e | ||
|
|
5a30cddd32 | ||
|
|
432cb669f5 | ||
|
|
26e1eb64c5 | ||
|
|
30e0e3b8f4 | ||
|
|
46e459aaf3 | ||
|
|
6949fa8201 | ||
|
|
fab2da4586 | ||
|
|
6a402c50ea | ||
|
|
bfeb8c70c0 | ||
|
|
cf61c7a747 | ||
|
|
a003059c8f | ||
|
|
854010ba52 | ||
|
|
07b82b1d87 | ||
|
|
5c204ad0f9 | ||
|
|
5bfca28450 | ||
|
|
a36da4cd84 | ||
|
|
f08a23ecf9 | ||
|
|
3432b0c73b | ||
|
|
c1c3d5c68f | ||
|
|
2a52534442 | ||
|
|
519cf27c4e | ||
|
|
9d07f26f13 | ||
|
|
fdfb60bee8 | ||
|
|
75c9e93241 | ||
|
|
a5ba5655b6 | ||
|
|
46056a0c53 | ||
|
|
05b2439ff6 | ||
|
|
663bf7562b | ||
|
|
58e29d81be | ||
|
|
027eeb8a49 | ||
|
|
e6a4bcd833 | ||
|
|
1da461e2de | ||
|
|
3c07cab8c2 | ||
|
|
110088688a | ||
|
|
d8243d6ea8 | ||
|
|
b1fdbcccf3 | ||
|
|
00205d4b1f | ||
|
|
6100f3ac2e | ||
|
|
6fa6de4543 | ||
|
|
d0bcb19754 | ||
|
|
0f69ed5502 | ||
|
|
039d761a27 | ||
|
|
e2dd081d44 | ||
|
|
7e75671618 | ||
|
|
5ca10042c8 | ||
|
|
64979ecaf0 | ||
|
|
0175c4cf27 | ||
|
|
531f6d96ae | ||
|
|
997be712f1 | ||
|
|
7e5dacf68d | ||
|
|
73fa56d401 | ||
|
|
e46977f50d | ||
|
|
28e1f6f65a | ||
|
|
6c257271bb | ||
|
|
36f567c834 | ||
|
|
c6627a0854 | ||
|
|
608db6d423 | ||
|
|
72168b56f8 | ||
|
|
5af20067b8 | ||
|
|
c8ae544c8b | ||
|
|
28fddc5550 | ||
|
|
ef635b2a9b | ||
|
|
ce69d57dc8 | ||
|
|
43aa6291c3 | ||
|
|
e9f63674ca | ||
|
|
41b97ba638 | ||
|
|
8a7c61b64a | ||
|
|
34dbcb7ea6 | ||
|
|
ca8122686b | ||
|
|
4472541008 | ||
|
|
e5c8dad319 | ||
|
|
3e82152ae7 | ||
|
|
4d2bd81bf0 | ||
|
|
aca45fb26e | ||
|
|
d13bb04648 | ||
|
|
8dc7593780 | ||
|
|
88005ea5d2 | ||
|
|
e86f4a88cc | ||
|
|
cf58ff3d3f | ||
|
|
32ac3632d0 | ||
|
|
6abf8531bb | ||
|
|
353964e75c | ||
|
|
6a376b20c7 | ||
|
|
162f0ceeb5 | ||
|
|
9dcb91af9e | ||
|
|
4d1ed0f357 | ||
|
|
d94c7ad003 | ||
|
|
edef36becb | ||
|
|
29a13f729a | ||
|
|
6a6bddc5c8 | ||
|
|
b12f184d18 | ||
|
|
45a68973b7 | ||
|
|
ab98cca421 | ||
|
|
413b189293 | ||
|
|
774b7bb1fc | ||
|
|
ff93d7f4d4 | ||
|
|
e3d9ff9ed3 | ||
|
|
6e2294e279 | ||
|
|
c17beeb908 | ||
|
|
5c65627582 | ||
|
|
8c0cafafa1 | ||
|
|
3450570d7e | ||
|
|
c650283446 | ||
|
|
28773ce4c2 | ||
|
|
e4b1d8088a | ||
|
|
1dc0669bae | ||
|
|
58eb9fe23c | ||
|
|
38617c827e | ||
|
|
a9939a1b5e | ||
|
|
2525805aac | ||
|
|
26a7b3b0de | ||
|
|
4bbc29591c | ||
|
|
49bfc65a03 | ||
|
|
d017c3194e | ||
|
|
a2ccedcecd | ||
|
|
3a7c455bb3 | ||
|
|
19087417b4 | ||
|
|
05c6878644 | ||
|
|
7e2f495f52 | ||
|
|
50e649daa3 | ||
|
|
629382f719 | ||
|
|
8835a9cd6a | ||
|
|
3e2eebdd9b | ||
|
|
acd2cc3222 | ||
|
|
9ef3787d4b | ||
|
|
47fd6bfe18 | ||
|
|
984010a8ec | ||
|
|
58543a34b3 | ||
|
|
293dc9f4c3 | ||
|
|
68c8d31dd1 | ||
|
|
96ef87886f | ||
|
|
48e3f43062 | ||
|
|
d74557d681 | ||
|
|
0e18e0908a | ||
|
|
958c13ca93 | ||
|
|
26de2cebeb | ||
|
|
bd8496a5e2 | ||
|
|
aa56239f54 | ||
|
|
a0b85111eb | ||
|
|
07b252ecc6 | ||
|
|
92b364e0f8 | ||
|
|
9247eb3098 | ||
|
|
9e0f5d7e22 | ||
|
|
db4b4d18cc | ||
|
|
92a464b2da | ||
|
|
7fed8db02a | ||
|
|
8161f4d9a0 | ||
|
|
c6661e71b1 | ||
|
|
6dc7ff761a | ||
|
|
de4a7d9f34 | ||
|
|
5f239583fd | ||
|
|
aff49aa8a9 | ||
|
|
6e10bffd40 | ||
|
|
37a4dcce18 | ||
|
|
c16da21602 | ||
|
|
55ea84f9a6 | ||
|
|
4341a828db | ||
|
|
255e36baa8 | ||
|
|
2fc4c8c153 | ||
|
|
79f5c7fcf4 | ||
|
|
743621ff51 | ||
|
|
9272498c9e | ||
|
|
e89aef78b5 | ||
|
|
3d41c56a0a | ||
|
|
e4060b7481 | ||
|
|
4e92053151 | ||
|
|
38700499d4 | ||
|
|
0e3fc032ab | ||
|
|
a604e0be10 | ||
|
|
264f36b89e | ||
|
|
2319a7dfb0 | ||
|
|
6549e2b8a2 | ||
|
|
ddfc88dad6 | ||
|
|
e1e3d4992d | ||
|
|
43b0f2fbf0 | ||
|
|
64ff72faa9 | ||
|
|
cc0af77e2f | ||
|
|
7086bdc9ab | ||
|
|
e627fd6f27 | ||
|
|
2ba9440966 | ||
|
|
cd51206462 | ||
|
|
0cb97db7eb | ||
|
|
421b438569 | ||
|
|
1d40baf4cd | ||
|
|
3c5fb46a4d | ||
|
|
5f06d726f7 | ||
|
|
bcd69f5836 | ||
|
|
654fd4c35c | ||
|
|
b98a87c1f5 | ||
|
|
0d29082793 | ||
|
|
2d56bc5953 | ||
|
|
1bfe3f4436 | ||
|
|
3f9f40800a | ||
|
|
a1c7969477 | ||
|
|
53cf637938 | ||
|
|
329bcba31c | ||
|
|
122cef6053 | ||
|
|
a8a8cf5862 | ||
|
|
74149c2c54 | ||
|
|
15975fdd78 | ||
|
|
99f0a4a208 | ||
|
|
4f9cd060be | ||
|
|
cd1d3dd379 | ||
|
|
b08f3d7b45 | ||
|
|
1531f3e912 | ||
|
|
6f415544be | ||
|
|
4eb52a592d | ||
|
|
b4823b90e7 | ||
|
|
5602c0a3b3 | ||
|
|
e0e53f24f1 | ||
|
|
9d8c687e4d | ||
|
|
aeb6a3ebb4 | ||
|
|
e2aa00b16d | ||
|
|
ac711d5f3d | ||
|
|
9d8b5d21b5 | ||
|
|
15ae6d4981 | ||
|
|
f063495cbb | ||
|
|
c5821faee8 | ||
|
|
f83a6e574c | ||
|
|
03661ccf4b | ||
|
|
2d5af74b1b | ||
|
|
ae8141c1a8 | ||
|
|
e9cf5e58de | ||
|
|
1950fe56bd | ||
|
|
94cacb14e7 | ||
|
|
a8dea78e24 | ||
|
|
8adcfb040a | ||
|
|
4f396737e4 | ||
|
|
36f8dd81cd | ||
|
|
f6aebc7d29 | ||
|
|
8a63aef3f0 | ||
|
|
4be37ceb14 | ||
|
|
d123fe6229 | ||
|
|
2f738fdba4 | ||
|
|
d52aa3246e | ||
|
|
e6e5258e5b | ||
|
|
753925ba99 | ||
|
|
684be8c0cf | ||
|
|
42715d3de2 | ||
|
|
4d21633462 | ||
|
|
f91abd319f | ||
|
|
43be11c636 | ||
|
|
72df79b9b8 | ||
|
|
6f331ea6d5 | ||
|
|
d137d5682d | ||
|
|
ea9f5254b7 | ||
|
|
577a19e35c | ||
|
|
f58d405b3b | ||
|
|
927d424d33 | ||
|
|
25e5d39a72 | ||
|
|
d8ed3d6bf8 | ||
|
|
c5a43524a1 | ||
|
|
adfc2d568b | ||
|
|
4c6797c631 | ||
|
|
e2710f6ed3 | ||
|
|
1b859f4ab6 | ||
|
|
f44ce4c311 | ||
|
|
d8dbbaa7a2 | ||
|
|
ddc85f2fd3 | ||
|
|
9cbe0b7c8b | ||
|
|
c83389e7c5 | ||
|
|
37d56b4197 | ||
|
|
01f69e2273 | ||
|
|
c5ada7e974 | ||
|
|
450d1c1861 | ||
|
|
aa10b3f600 | ||
|
|
b65bd0ff44 | ||
|
|
86d28136de | ||
|
|
707fcc2aa1 | ||
|
|
caabf6a54c | ||
|
|
d910d09e00 | ||
|
|
de53ed9258 | ||
|
|
81188ae30f | ||
|
|
f5d361661f | ||
|
|
c298bc1dbf | ||
|
|
0ad80a63cf | ||
|
|
bc76adf8eb | ||
|
|
cc7142e5c1 | ||
|
|
a975b8ae70 | ||
|
|
3b34a87391 | ||
|
|
453f56c7c8 | ||
|
|
a131a9f9fb | ||
|
|
d5f9af1954 | ||
|
|
a44d2633a1 | ||
|
|
de2a46eb93 |
66
.env
66
.env
@@ -1,19 +1,49 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
INSIGHTS_BASE_URL=
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=null
|
||||
ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
TWITTER_URL=null
|
||||
STUDIO_BASE_URL=
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
|
||||
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=''
|
||||
LMS_BASE_URL=''
|
||||
LOGIN_URL=''
|
||||
LOGOUT_URL=''
|
||||
LOGO_URL=''
|
||||
LOGO_TRADEMARK_URL=''
|
||||
LOGO_WHITE_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=''
|
||||
SUPPORT_URL=''
|
||||
SUPPORT_URL_CALCULATOR_MATH=''
|
||||
SUPPORT_URL_ID_VERIFICATION=''
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='development'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
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'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
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'
|
||||
SEGMENT_KEY=null
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='edX'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
37
.env.test
37
.env.test
@@ -1,19 +1,48 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
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'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
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'
|
||||
SEGMENT_KEY=null
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='edX'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
coverage/*
|
||||
dist/
|
||||
packages/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
jest.config.js
|
||||
|
||||
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 }}
|
||||
23
.github/workflows/validate.yml
vendored
Normal file
23
.github/workflows/validate.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: validate
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -8,11 +10,18 @@ coverage
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
logs
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
### Emacs ###
|
||||
### Editors ###
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
|
||||
# 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
|
||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint
|
||||
15
.travis.yml
15
.travis.yml
@@ -1,15 +0,0 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
before_install:
|
||||
- npm install -g npm@6
|
||||
install:
|
||||
- npm ci
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
- npm run is-es5
|
||||
after_success:
|
||||
- codecov
|
||||
@@ -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
|
||||
|
||||
|
||||
43
Makefile
43
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,17 +37,45 @@ 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:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
.PHONY: validate
|
||||
validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
npm ci
|
||||
make validate
|
||||
|
||||
231
README.rst
231
README.rst
@@ -1,45 +1,216 @@
|
||||
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|
||||
#####################
|
||||
frontend-app-learning
|
||||
######################
|
||||
|
||||
|codecov| |license|
|
||||
|
||||
********
|
||||
Purpose
|
||||
********
|
||||
|
||||
This is the Learning MFE (micro-frontend application), which renders all
|
||||
learner-facing course pages (like the course outline, the progress page,
|
||||
actual course content, etc).
|
||||
|
||||
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/openedx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
***************
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
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.
|
||||
|
||||
- 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
|
||||
=========================
|
||||
|
||||
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
|
||||
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::
|
||||
|
||||
Introduction
|
||||
------------
|
||||
module.exports = {
|
||||
/*
|
||||
Modules you want to use from local source code. Adding a module here means that when this app
|
||||
runs its build, it'll resolve the source from peer directories of this app.
|
||||
|
||||
React app for edX learning.
|
||||
moduleName: the name you use to import code from the module.
|
||||
dir: The relative path to the module's source code.
|
||||
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
|
||||
may want to use "src" if the module installs React as a peer/dev dependency.
|
||||
*/
|
||||
localModules: [
|
||||
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://travis-ci.org/edx/frontend-app-learning
|
||||
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://coveralls.io/github/edx/frontend-app-learning
|
||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
Development
|
||||
-----------
|
||||
Deployment
|
||||
==========
|
||||
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
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>`_.
|
||||
|
||||
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
Environment Variables
|
||||
======================
|
||||
|
||||
- Start devstack
|
||||
- Log in (http://localhost:18000/login)
|
||||
This MFE is configured via environment variables supplied at build time.
|
||||
All micro-frontends have a shared set of required environment variables,
|
||||
as documented in the Open edX Developer Guide under
|
||||
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
|
||||
|
||||
Start the development server
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
The learning micro-frontend also supports the following additional variables:
|
||||
|
||||
In this project, install requirements and start the development server by running:
|
||||
CREDIT_HELP_LINK_URL
|
||||
A link to resources to help explain what course credit is and how to earn it.
|
||||
|
||||
.. code:: bash
|
||||
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
|
||||
|
||||
npm install
|
||||
npm start # The server will run on port 1995
|
||||
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.
|
||||
|
||||
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
Example: ``milestone``
|
||||
|
||||
SUPPORT_URL_CALCULATOR_MATH
|
||||
A link that explains how to use the in-course calculator. You can use the
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
|
||||
|
||||
SUPPORT_URL_ID_VERIFICATION
|
||||
A link that explains how to verify your ID. Shown in contexts where you need
|
||||
to verify yourself to earn a certificate. The example link below is probably too
|
||||
edx.org-specific to use for your own site.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity
|
||||
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE
|
||||
A link that explains what a verified certificate is. You can use the
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
Optional.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
|
||||
|
||||
TWITTER_HASHTAG
|
||||
This value is used in the Twitter social-share link when celebrating learning
|
||||
milestones in the course. Will prefill the suggested post with this hashtag.
|
||||
Optional.
|
||||
|
||||
Example: ``brandedhashtag``
|
||||
|
||||
TWITTER_URL
|
||||
A link to your Twitter account. The Twitter social-share link won't appear
|
||||
unless this is set. Optional.
|
||||
|
||||
Example: https://twitter.com/edXOnline
|
||||
|
||||
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.
|
||||
@@ -37,6 +39,9 @@ Today, if the URL only specifies the course ID, we need to pick a sequence to sh
|
||||
|
||||
Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame.
|
||||
|
||||
_This URL scheme has been expanded upon in
|
||||
[ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
|
||||
|
||||
## "Container" components vs. display components
|
||||
|
||||
This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer.
|
||||
|
||||
66
docs/decisions/0007-testing.md
Normal file
66
docs/decisions/0007-testing.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Testing
|
||||
|
||||
## Status
|
||||
Draft
|
||||
|
||||
Let's live with this a bit longer before deciding it's a solid approach and marking this Approved.
|
||||
|
||||
## Context
|
||||
We'd like to all be on the same page about how to approach testing, what is
|
||||
worth testing, and how to do it.
|
||||
|
||||
## React Testing Library
|
||||
We'll use react-testing-library and jest as the main testing tools.
|
||||
|
||||
This has some implications about how to test. You can read the React Testing Library's
|
||||
[Guiding Principles](https://testing-library.com/docs/guiding-principles), but the main
|
||||
takeaway is that you should be interacting with React as closely as possible to the way
|
||||
the user will interact with it.
|
||||
|
||||
For example, they discourage using class or element name selectors to find components
|
||||
during a test. Instead, you should find them by user-oriented attributes like labels,
|
||||
text, or roles. As a last resort, by a `data-testid` tag.
|
||||
|
||||
## Mocking data
|
||||
We'll use [Rosie](https://github.com/rosiejs/rosie) as a tool for building JavaScript objects.
|
||||
Our main use case for Rosie is to use factories in order to mock the data we'd like to fetch when rendering components.
|
||||
[axios-mock-adapter](https://www.npmjs.com/package/axios-mock-adapter) allows us to mock the response of an HTTP request.
|
||||
|
||||
For example, we may use a factory to build a course metadata object:
|
||||
|
||||
`const courseMetadata = Factory.build('courseMetadata');`
|
||||
|
||||
Then we'd pass that `courseMetadata` object into an axios mock call:
|
||||
|
||||
`axiosMock.onGet('example.com').reply(200, courseMetadata);`
|
||||
|
||||
This way, when a component sends a GET request to `example.com` within the test's lifecycle, the request will be intercepted
|
||||
by the axios-mock-adapter, and the courseMetadata object will be returned.
|
||||
|
||||
These factories should live within the data directories they intend to mock
|
||||
```
|
||||
courseware
|
||||
| data
|
||||
| __factories__
|
||||
| courseMetadata.factory.js /* used to define the Rosie factory */
|
||||
| api.js /* getCourseMetadata() lives here */
|
||||
```
|
||||
|
||||
## What to Test
|
||||
We have not found exhaustive unit testing of frontend code to be worth the trouble.
|
||||
Rather, let's focus on testing non-obvious behavior.
|
||||
|
||||
In essence: `test behavior that wouldn't present itself to a developer playing around`.
|
||||
|
||||
Practically speaking, this means error states, interactive components, corner cases,
|
||||
or anything that wouldn't come up in a demo course. Something a developer wouldn't
|
||||
notice in the normal course of working in devstack.
|
||||
|
||||
## Snapshots
|
||||
In practice, we've found snapshots of component trees to be too brittle to be worth it,
|
||||
as refactors occur or external libraries change.
|
||||
|
||||
They can still be useful for data (like redux tests) or tiny isolated components.
|
||||
|
||||
But please avoid for any "interesting" component. Prefer inspecting the explicit behavior
|
||||
under test, rather than just snapshotting the entire component tree.
|
||||
90
docs/decisions/0008-liberal-courseware-path-handling.md
Normal file
90
docs/decisions/0008-liberal-courseware-path-handling.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Liberal courseware path handling
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
_This updates some of the content in [ADR #2: Courseware page decisions](./0002-courseware-page-decisions.md)._
|
||||
|
||||
## Context
|
||||
|
||||
The courseware container currently accepts three path forms:
|
||||
|
||||
1. `/course/:courseId`
|
||||
2. `/course/:courseId/:sequenceId`
|
||||
3. `/course/:courseId/:sequenceId/:unitId`
|
||||
|
||||
Forms #1 and #2 are always redirected to Form #3 via simple set of rules:
|
||||
|
||||
* If the sequenceId is not specified, choose the first sequence in the course.
|
||||
* If the unitId is not specified, choose the active unit in the sequence,
|
||||
or the first unit if none are active.
|
||||
|
||||
Thus, Form #3 is effectively the canonoical path;
|
||||
all Learning MFE units should be served from it.
|
||||
We acknowledge that the best user experience is to link directly to the canonoical
|
||||
path when possible, since it skips the redirection steps.
|
||||
Still, there are times when it is necessary or prudent to link just to a course or
|
||||
a sequence.
|
||||
|
||||
Through recent work in the LMS, we are realizing that there are _also_ times where it
|
||||
would be simpler or more performant to link a user to an
|
||||
_entire section without specifying a squence_ or to a
|
||||
_unit without including the sequence_.
|
||||
Specifically, this capability would let as avoid further modulestore or
|
||||
block transformer queries in order to discern the course structure when trying to
|
||||
direct a learner to a section or unit.
|
||||
Futhermore, we hypothesize that being able to build a Learning MFE courseware link
|
||||
with just a unit ID or a section ID will be a nice simplifying quality for future
|
||||
development or debugging.
|
||||
|
||||
## Decision
|
||||
|
||||
The courseware container will accept five total path forms:
|
||||
|
||||
1. `/course/:courseId`
|
||||
2. `/course/:courseId/:sectionId`
|
||||
3. `/course/:courseId/:sectionId/:unitId`
|
||||
4. `/course/:courseId/:sequenceId`
|
||||
5. `/course/:courseId/:unitId`
|
||||
6. `/course/:courseId/:sequenceId/:unitId`
|
||||
|
||||
The redirection rules are as follows:
|
||||
|
||||
* Forms #1 redirects to Form #4 by selecting the first sequence in the course.
|
||||
* Form #2 redirects to Form #4 by selecting to the first sequence in the section.
|
||||
* Form #3 redirects to Form #5 by dropping the section ID.
|
||||
* Form #4 redirects to Form #6 by choosing the active unit in the sequence
|
||||
(or the first unit, if none are active).
|
||||
* Form #5 redirects to Form #6 by filling in the ID of the sequence that the
|
||||
specified unit belongs to (in the edge case where the unit belongs to multiple
|
||||
sequences, the first sequence is selected).
|
||||
|
||||
As before, Form #5 is the canonocial courseware path, which is always redirected to
|
||||
by any of the other courseware path forms.
|
||||
|
||||
## Consequences
|
||||
|
||||
The above decision is implemented.
|
||||
|
||||
## Further work
|
||||
|
||||
At some point, we may decide to further extend the URL scheme to be
|
||||
more human-readable.
|
||||
|
||||
We can't make UsageKeys themselves more readable because they're tied to student state,
|
||||
but we could introduce a new optional `slug` field on Sequences,
|
||||
which would be captured and propagated to the learning_sequences API.
|
||||
We could eventually do something similar to Units, since those slugs only have to be sequence-local.
|
||||
|
||||
So eventually, URLs could look less like:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/block-v1:edX+DemoX.1+2T2019+type@sequential+block@e0a61b3d5a2046949e76d12cac5df493/block-v1:edX+DemoX.1+2T2019+type@vertical+block@52dbad5a28e140f291a476f92ce11996
|
||||
```
|
||||
|
||||
And more like:
|
||||
```
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/Being_Social/Teams
|
||||
```
|
||||
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.
|
||||
@@ -7,5 +7,14 @@ module.exports = createConfig('jest', {
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'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'
|
||||
});
|
||||
|
||||
44841
package-lock.json
generated
44841
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
102
package.json
102
package.json
@@ -4,74 +4,80 @@
|
||||
"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",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
"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/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-platform": "1.5.2",
|
||||
"@edx/paragon": "^9.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.30",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
||||
"@fortawesome/react-fontawesome": "0.1.11",
|
||||
"@reduxjs/toolkit": "1.3.6",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.13.1",
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-helmet": "6.0.0",
|
||||
"react-redux": "7.2.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-share": "4.2.1",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"reselect": "4.0.0"
|
||||
"@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.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.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.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.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "5.0.6",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.10.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.0.17",
|
||||
"axios-mock-adapter": "1.18.2",
|
||||
"codecov": "3.7.2",
|
||||
"es-check": "5.1.0",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.0.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
|
||||
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
11
public/static/LmsHtmlFragment.css
Normal file
11
public/static/LmsHtmlFragment.css
Normal file
@@ -0,0 +1,11 @@
|
||||
body a {
|
||||
color: #00688D;
|
||||
}
|
||||
|
||||
body.inline-link a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.small {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -5,5 +5,12 @@
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import PageLoading from './generic/PageLoading';
|
||||
|
||||
export default () => {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
<div className="flex-grow-1">
|
||||
<PageLoading srMessage={(
|
||||
<FormattedMessage
|
||||
id="learn.redirect.interstitial.message"
|
||||
description="The screen-reader message when a page is about to redirect"
|
||||
defaultMessage="Redirecting..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<Route
|
||||
path={`${path}/course-home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={`${path}/dashboard`}
|
||||
render={({ location }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,137 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
|
||||
function AccessExpirationAlert({ payload }) {
|
||||
const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
const {
|
||||
rawHtml,
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
} = payload;
|
||||
return rawHtml && (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
upgradeDeadline,
|
||||
upgradeUrl,
|
||||
} = accessExpiration;
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: `${analyticsPageName}_audit_access_expires`,
|
||||
linkType: 'link',
|
||||
pageName: analyticsPageName,
|
||||
});
|
||||
};
|
||||
|
||||
let deadlineMessage = null;
|
||||
if (upgradeDeadline && upgradeUrl) {
|
||||
deadlineMessage = (
|
||||
<>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.deadline"
|
||||
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
|
||||
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationUpgradeDeadline"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={upgradeDeadline}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
defaultMessage="Audit Access Expires {date}"
|
||||
description="Headline for auditing deadline"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationHeaderDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.body"
|
||||
defaultMessage="You lose all access to this course, including your progress, on {date}."
|
||||
description="Message body to tell learner the consequences of course expiration."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationBodyDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{deadlineMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
upgradeUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
analyticsPageName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlert;
|
||||
export default injectIntl(AccessExpirationAlert);
|
||||
|
||||
@@ -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,13 +1,19 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
|
||||
|
||||
function useAccessExpirationAlert(courseExpiredMessage, topic) {
|
||||
const rawHtml = courseExpiredMessage || null;
|
||||
const isVisible = !!rawHtml; // If it exists, show it.
|
||||
|
||||
const payload = useMemo(() => ({ rawHtml }), [rawHtml]);
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
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',
|
||||
@@ -18,4 +24,28 @@ function useAccessExpirationAlert(courseExpiredMessage, topic) {
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
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: 'clientAccessExpirationMasqueradeBanner',
|
||||
payload,
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from './hooks';
|
||||
export { default, useAccessExpirationMasqueradeBanner } from './hooks';
|
||||
|
||||
11
src/alerts/access-expiration-alert/messages.js
Normal file
11
src/alerts/access-expiration-alert/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.accessExpiration.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'The anchor text for the upgrading link',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
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,34 +3,44 @@ 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';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
||||
|
||||
function CourseStartAlert({ payload }) {
|
||||
const CourseStartAlert = ({ payload }) => {
|
||||
const {
|
||||
delta,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert variant="info" icon={Info}>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.short"
|
||||
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
|
||||
@@ -42,7 +52,6 @@ function CourseStartAlert({ payload }) {
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
@@ -56,10 +65,10 @@ function CourseStartAlert({ payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<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={{
|
||||
@@ -79,18 +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({
|
||||
delta: PropTypes.number,
|
||||
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';
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { Info, WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
import { useEnrollClickHandler } from './hooks';
|
||||
import useEnrollClickHandler from './clickHook';
|
||||
|
||||
function EnrollmentAlert({ intl, payload }) {
|
||||
const EnrollmentAlert = ({ intl, payload }) => {
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
@@ -18,36 +19,43 @@ function EnrollmentAlert({ intl, payload }) {
|
||||
isStaff,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
org,
|
||||
intl.formatMessage(messages.success),
|
||||
);
|
||||
|
||||
let text = intl.formatMessage(messages.alert);
|
||||
let type = ALERT_TYPES.ERROR;
|
||||
let type = 'warning';
|
||||
let icon = WarningFilled;
|
||||
if (isStaff) {
|
||||
text = intl.formatMessage(messages.staffAlert);
|
||||
type = ALERT_TYPES.INFO;
|
||||
type = 'info';
|
||||
icon = Info;
|
||||
} else if (extraText) {
|
||||
text = `${text} ${extraText}`;
|
||||
}
|
||||
|
||||
const button = canEnroll && (
|
||||
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages.enroll)}
|
||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top mx-1" size="sm" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages.enrollNowSentence)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type={type}>
|
||||
{text}
|
||||
{' '}
|
||||
{button}
|
||||
{' '}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
<Alert variant={type} icon={icon}>
|
||||
<div className="d-flex">
|
||||
{text}
|
||||
{button}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
35
src/alerts/enrollment-alert/clickHook.js
Normal file
35
src/alerts/enrollment-alert/clickHook.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useContext, useState, useCallback } from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
// Separated into its own file to avoid a circular dependency inside this directory
|
||||
|
||||
function useEnrollClickHandler(courseId, orgId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
sendTrackEvent('edx.bi.user.course-home.enrollment', {
|
||||
org_key: orgId,
|
||||
courserun_key: courseId,
|
||||
});
|
||||
global.location.reload();
|
||||
});
|
||||
}, [addFlash, courseId, orgId, successText]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
export default useEnrollClickHandler;
|
||||
@@ -1,51 +1,39 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, {
|
||||
useContext, useState, useCallback,
|
||||
useContext, useMemo,
|
||||
} from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const course = useModel('courseHomeMeta', courseId);
|
||||
const outline = useModel('outline', courseId);
|
||||
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
|
||||
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. the user is not enrolled,
|
||||
* 2. the user is authenticated, AND
|
||||
* 3. the course is private.
|
||||
*/
|
||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
||||
const payload = 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: {
|
||||
canEnroll: outline.enrollAlert.canEnroll,
|
||||
courseId,
|
||||
extraText: outline.enrollAlert.extraText,
|
||||
isStaff: course.isStaff,
|
||||
},
|
||||
payload,
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
return { clientEnrollmentAlert: EnrollmentAlert };
|
||||
}
|
||||
|
||||
export function useEnrollClickHandler(courseId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
global.location.reload();
|
||||
});
|
||||
}, [courseId]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
@@ -11,9 +11,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
enroll: {
|
||||
id: 'learning.enrollment.enroll.now',
|
||||
defaultMessage: 'Enroll Now',
|
||||
enrollNowInline: {
|
||||
id: 'learning.enrollment.enrollNow.Inline',
|
||||
defaultMessage: 'Enroll now',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.'
|
||||
+ 'This text is meant to be used at the beginning of a sentence (example: Enroll now to view course content.)',
|
||||
},
|
||||
enrollNowSentence: {
|
||||
id: 'learning.enrollment.enrollNow.Sentence',
|
||||
defaultMessage: 'Enroll now.',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
||||
},
|
||||
success: {
|
||||
|
||||
132
src/alerts/logistration-alert/AccountActivationAlert.jsx
Normal file
132
src/alerts/logistration-alert/AccountActivationAlert.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
AlertModal,
|
||||
Button,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Check, ArrowForward } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { sendActivationEmail } from '../../courseware/data';
|
||||
import messages from './messages';
|
||||
|
||||
const AccountActivationAlert = ({
|
||||
intl,
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
const handleOnClick = () => {
|
||||
setShowSpinner(true);
|
||||
setShowCheck(false);
|
||||
sendActivationEmail().then(() => {
|
||||
setShowSpinner(false);
|
||||
setShowCheck(true);
|
||||
});
|
||||
};
|
||||
|
||||
const showAccountActivationAlert = Cookies.get('show-account-activation-popup');
|
||||
if (showAccountActivationAlert !== undefined) {
|
||||
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
|
||||
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
|
||||
// of cookie would make it infinite rendering
|
||||
if (Cookies.get('show-account-activation-popup') === undefined) {
|
||||
setShowModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="primary"
|
||||
className=""
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.button"
|
||||
defaultMessage="Continue to {siteName}"
|
||||
description="account activation alert continue button"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
<Icon src={ArrowForward} className="ml-1 d-inline-block align-bottom" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const children = () => {
|
||||
let bodyContent;
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.message"
|
||||
defaultMessage="We sent an email to {boldEmail} with a link to activate your account. Can’t find it? Check your spam folder or
|
||||
{sendEmailTag}."
|
||||
description="Message for account activation alert which is shown after the registration"
|
||||
values={{
|
||||
boldEmail: <b>{getAuthenticatedUser() && getAuthenticatedUser().email}</b>,
|
||||
sendEmailTag: (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-is-valid
|
||||
<a href="#" role="button" onClick={handleOnClick}>
|
||||
<FormattedMessage
|
||||
id="account-activation.resend.link"
|
||||
defaultMessage="resend the email"
|
||||
description="Message for resend link in account activation alert which is shown after the registration"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showCheck && showSpinner) {
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
<Spinner
|
||||
animation="border"
|
||||
variant="secondary"
|
||||
style={{ height: '1.5rem', width: '1.5rem' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCheck && !showSpinner) {
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
<Icon
|
||||
src={Check}
|
||||
style={{ height: '1.7rem', width: '1.25rem' }}
|
||||
className="text-success-500 d-inline-block position-fixed"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return bodyContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={showModal}
|
||||
title={intl.formatMessage(messages.accountActivationAlertTitle)}
|
||||
footerNode={button}
|
||||
onClose={() => ({})}
|
||||
>
|
||||
{children()}
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
AccountActivationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccountActivationAlert);
|
||||
@@ -2,31 +2,38 @@ import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { Alert } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
function LogistrationAlert({ intl }) {
|
||||
const LogistrationAlert = ({ intl }) => {
|
||||
const signIn = (
|
||||
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
|
||||
{intl.formatMessage(messages.login)}
|
||||
</a>
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.signInLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
// TODO: Pull this registration URL building out into a function, like the login one above.
|
||||
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
|
||||
const register = (
|
||||
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
|
||||
{intl.formatMessage(messages.register)}
|
||||
</a>
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.registerLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type="error">
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
<FormattedMessage
|
||||
id="learning.logistration.alert"
|
||||
description="Prompts the user to sign in or register to see course content."
|
||||
defaultMessage="Please {signIn} or {register} to see course content."
|
||||
defaultMessage="To see course content, {signIn} or {register}."
|
||||
values={{
|
||||
signIn,
|
||||
register,
|
||||
@@ -34,7 +41,7 @@ function LogistrationAlert({ intl }) {
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
LogistrationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
|
||||
|
||||
export function useLogistrationAlert() {
|
||||
export function useLogistrationAlert(courseId) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const isVisible = authenticatedUser === null;
|
||||
const outline = useModel('outline', courseId);
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. the user is not authenticated, AND
|
||||
* 2. the course is private.
|
||||
*/
|
||||
const isVisible = authenticatedUser === null && privateOutline;
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientLogistrationAlert',
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
login: {
|
||||
id: 'learning.logistration.login',
|
||||
defaultMessage: 'sign in',
|
||||
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
|
||||
},
|
||||
register: {
|
||||
id: 'learning.logistration.register',
|
||||
defaultMessage: 'register',
|
||||
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
function OfferAlert({ payload }) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = payload;
|
||||
return rawHtml && (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
OfferAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default OfferAlert;
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
|
||||
export function useOfferAlert(offerHtml, topic) {
|
||||
const rawHtml = offerHtml || null;
|
||||
const isVisible = !!rawHtml; // if it exists, show it.
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientOfferAlert',
|
||||
topic,
|
||||
payload: { rawHtml },
|
||||
});
|
||||
|
||||
return { clientOfferAlert: OfferAlert };
|
||||
}
|
||||
|
||||
export default useOfferAlert;
|
||||
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;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
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,80 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import logo from './assets/logo.svg';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
courseOrg, courseNumber, courseTitle,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={logo}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
</div>
|
||||
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Button>
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
{authenticatedUser.username}
|
||||
</span>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
|
||||
<title>logo</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
|
||||
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
|
||||
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
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,24 +1,122 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
.sequence(
|
||||
'course_id',
|
||||
(courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
|
||||
)
|
||||
.option('courseTabs', [])
|
||||
.extend(courseMetadataBase)
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
is_staff: false,
|
||||
original_user_is_staff: false,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
is_staff: false,
|
||||
can_view_certificate: true,
|
||||
celebrations: null,
|
||||
course_access: {
|
||||
additional_context_user_message: null,
|
||||
developer_message: null,
|
||||
error_code: null,
|
||||
has_access: true,
|
||||
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', ['courseTabs', 'host'], (courseTabs, host) => courseTabs.map(
|
||||
tab => ({
|
||||
tab_id: tab.slug,
|
||||
title: tab.title,
|
||||
url: `${host}${tab.url}`,
|
||||
}),
|
||||
));
|
||||
.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',
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -1,27 +1,222 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
// Sample data helpful when developing & testing, to see a variety of configurations.
|
||||
// This set of data is not realistic (mix of having access and not), but it
|
||||
// is intended to demonstrate many UI results.
|
||||
Factory.define('datesTabData')
|
||||
.attrs({
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
missed_deadlines: false,
|
||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
},
|
||||
course_date_blocks: [
|
||||
{
|
||||
assigment_type: 'Homework',
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
date: '2020-05-01T17:59:41Z',
|
||||
date_type: 'course-start-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Starts',
|
||||
extraInfo: '',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-04T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Multi Badges Completed',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-05T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Multi Badges Past Due',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-27T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Past Due 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-27T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Past Due 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-28T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Completed/Due 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-28T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Completed/Due 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-29T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Completed 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-29T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Completed 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2020-06-16T17:59:40.942669Z',
|
||||
date_type: 'verified-upgrade-deadline',
|
||||
description: "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Upgrade to Verified Certificate',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Verified 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Verified 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'ORA Verified 2',
|
||||
extra_info: "ORA Dates are set by the instructor, and can't be changed",
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-18T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Verified 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-18T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Verified 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-19T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'One Unreleased 1',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-19T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Unreleased 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Both Unreleased 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Both Unreleased 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2030-08-23T00:00:00Z',
|
||||
date_type: 'course-end-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Ends',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2030-09-01T00:00:00Z',
|
||||
date_type: 'verification-deadline-date',
|
||||
description: 'You must successfully complete verification before this date to qualify for a Verified Certificate.',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Verification Deadline',
|
||||
extra_info: null,
|
||||
},
|
||||
],
|
||||
missed_deadlines: false,
|
||||
missed_gated_content: false,
|
||||
has_ended: false,
|
||||
learner_is_full_access: true,
|
||||
user_timezone: null,
|
||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
import './progressTabData.factory';
|
||||
import './upgradeNotificationData.factory';
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/courseBlocks.factory';
|
||||
import { buildMinimalCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
|
||||
|
||||
Factory.define('outlineTabData')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attr('course_expired_html', [], () => '<div>Course expired</div>')
|
||||
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ({
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
url: `${host}/courses/${courseId}/bookmarks/`,
|
||||
}))
|
||||
.option('date_blocks', [])
|
||||
.attr('course_blocks', ['courseId'], courseId => {
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId);
|
||||
const { courseBlocks } = buildMinimalCourseBlocks(courseId);
|
||||
return {
|
||||
blocks: courseBlocks.blocks,
|
||||
};
|
||||
})
|
||||
.attr('enroll_alert', {
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
})
|
||||
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>')
|
||||
.attr('offer_html', [], () => '<div>Great offer here</div>');
|
||||
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
|
||||
course_date_blocks: dateBlocks,
|
||||
}))
|
||||
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
|
||||
has_visited_course: false,
|
||||
url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
|
||||
}))
|
||||
.attr('verified_mode', ['host'], (host) => ({
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
course_access_redirect: false,
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
cert_data: {
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
},
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
selected_goal: null,
|
||||
weekly_learning_goal_enabled: false,
|
||||
days_per_week: null,
|
||||
subscribed_to_reminders: null,
|
||||
},
|
||||
course_tools: [
|
||||
{
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
url: 'https://example.com/bookmarks',
|
||||
},
|
||||
],
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
missed_deadlines: false,
|
||||
},
|
||||
enroll_alert: {
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
},
|
||||
handouts_html: '<ul><li>Handout 1</li></ul>',
|
||||
offer: null,
|
||||
welcome_message_html: '<p>Welcome to this course!</p>',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
// Sample data helpful when developing & testing, to see a variety of configurations.
|
||||
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
|
||||
Factory.define('progressTabData')
|
||||
.attrs({
|
||||
access_expiration: null,
|
||||
end: '3027-03-31T00:00:00Z',
|
||||
certificate_data: {},
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
incomplete_count: 1,
|
||||
locked_count: 0,
|
||||
},
|
||||
course_grade: {
|
||||
letter_grade: 'pass',
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
credit_course_requirements: null,
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 0,
|
||||
num_points_possible: 3,
|
||||
percent_graded: 0.0,
|
||||
problem_scores: [{ earned: 0, possible: 1 }, { earned: 0, possible: 1 }, { earned: 0, possible: 1 }],
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 1.0,
|
||||
problem_scores: [{ earned: 1, possible: 1 }],
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
enrollment_mode: 'audit',
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
has_scheduled_content: false,
|
||||
studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run',
|
||||
user_has_passing_grade: false,
|
||||
verification_data: {
|
||||
link: null,
|
||||
status: 'none',
|
||||
status_date: null,
|
||||
},
|
||||
verified_mode: null,
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('upgradeNotificationData')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('upsellPageName', 'test')
|
||||
.attr('verifiedMode', ['host'], (host) => ({
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attrs({
|
||||
accessExpiration: {
|
||||
expiration_date: '1950-07-13T02:04:49.040006Z',
|
||||
},
|
||||
})
|
||||
.attr('timeOffsetMillis', 0);
|
||||
@@ -1,219 +1,729 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Should initialize store 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"displayResetDatesToast": false,
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {},
|
||||
}
|
||||
`;
|
||||
|
||||
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",
|
||||
"displayResetDatesToast": false,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courses": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "courseware",
|
||||
"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/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"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 {
|
||||
"assigmentType": "Homework",
|
||||
"date": "2013-02-05T05:00:00Z",
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
"extraInfo": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Completed",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
},
|
||||
Object {
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"dateType": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"learnerHasAccess": true,
|
||||
"title": "One Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"dateType": "course-end-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
},
|
||||
Object {
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"dateType": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Verification Deadline",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"hasEnded": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"learnerIsFullAccess": true,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"userTimezone": null,
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"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",
|
||||
"displayResetDatesToast": false,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courses": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "courseware",
|
||||
"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/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"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,
|
||||
},
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object {
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"unitIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "Title of Section",
|
||||
},
|
||||
},
|
||||
"units": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"graded": false,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
"effortActivities": 2,
|
||||
"effortTime": 15,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "Title of Sequence",
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseExpiredHtml": "<div>Course expired</div>",
|
||||
"courseTools": Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
||||
"courseGoals": Object {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"datesWidget": undefined,
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"enrollmentMode": undefined,
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"offerHtml": "<div>Great offer here</div>",
|
||||
"welcomeMessageHtml": undefined,
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"timeOffsetMillis": 0,
|
||||
"userHasPassingGrade": undefined,
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "ABCD1234",
|
||||
"upgradeUrl": "http://localhost:18000/dashboard",
|
||||
},
|
||||
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"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",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"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/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "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/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "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": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"accessExpiration": null,
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"completeCount": 1,
|
||||
"incompleteCount": 1,
|
||||
"lockedCount": 0,
|
||||
},
|
||||
"courseGrade": Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
},
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"creditCourseRequirements": null,
|
||||
"end": "3027-03-31T00:00:00Z",
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": "1.00",
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
"weight": 1,
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": Object {
|
||||
"pass": 0.75,
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"displayName": "First section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"learnerHasAccess": true,
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 3,
|
||||
"percentGraded": 0,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
],
|
||||
"showCorrectness": "always",
|
||||
"showGrades": true,
|
||||
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"displayName": "Second section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"displayName": "Second subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"numPointsEarned": 1,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 1,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 1,
|
||||
"possible": 1,
|
||||
},
|
||||
],
|
||||
"showCorrectness": "always",
|
||||
"showGrades": true,
|
||||
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
|
||||
"userHasPassingGrade": false,
|
||||
"verificationData": Object {
|
||||
"link": null,
|
||||
"status": "none",
|
||||
"statusDate": null,
|
||||
},
|
||||
"verifiedMode": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,101 +1,429 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
// TODO: Pull this normalization function up so we're not reaching into courseware
|
||||
import { normalizeBlocks } from '../../courseware/data/api';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
|
||||
let dropCount = numDroppable;
|
||||
// Drop the lowest grades
|
||||
while (dropCount && points.length >= dropCount) {
|
||||
const lowestScore = Math.min(...points);
|
||||
const lowestScoreIndex = points.indexOf(lowestScore);
|
||||
points.splice(lowestScoreIndex, 1);
|
||||
dropCount--;
|
||||
}
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
// 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 };
|
||||
};
|
||||
|
||||
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
const gradeByAssignmentType = {};
|
||||
assignmentPolicies.forEach(assignment => {
|
||||
// Create an array with the number of total assignments and set the scores to 0
|
||||
// as placeholders for assignments that have not yet been released
|
||||
gradeByAssignmentType[assignment.type] = {
|
||||
grades: Array(assignment.numTotal).fill(0),
|
||||
numAssignmentsCreated: 0,
|
||||
numTotalExpectedAssignments: assignment.numTotal,
|
||||
};
|
||||
});
|
||||
|
||||
sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
assignmentType,
|
||||
numPointsEarned,
|
||||
numPointsPossible,
|
||||
} = subsection;
|
||||
|
||||
// If a subsection's assignment type does not match an assignment policy in Studio,
|
||||
// we won't be able to include it in this accumulation of grades by assignment type.
|
||||
// This may happen if a course author has removed/renamed an assignment policy in Studio and
|
||||
// neglected to update the subsection's of that assignment type
|
||||
if (!gradeByAssignmentType[assignmentType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
numAssignmentsCreated,
|
||||
} = gradeByAssignmentType[assignmentType];
|
||||
|
||||
numAssignmentsCreated++;
|
||||
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
|
||||
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
|
||||
// of expected assignments
|
||||
gradeByAssignmentType[assignmentType].grades.shift();
|
||||
}
|
||||
// Add the graded assignment to the list
|
||||
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
|
||||
// Record the created assignment
|
||||
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
|
||||
});
|
||||
});
|
||||
|
||||
return assignmentPolicies.map((assignment) => {
|
||||
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
|
||||
gradeByAssignmentType[assignment.type].grades,
|
||||
assignment.weight,
|
||||
assignment.numDroppable,
|
||||
);
|
||||
|
||||
return {
|
||||
averageGrade,
|
||||
numDroppable: assignment.numDroppable,
|
||||
shortLabel: assignment.shortLabel,
|
||||
type: assignment.type,
|
||||
weight: assignment.weight,
|
||||
weightedGrade,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 => ({
|
||||
slug: tab.tabId,
|
||||
// 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' ? rootSlug : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
Object.values(blocks).forEach(block => {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
models.courses[block.id] = {
|
||||
id: courseId,
|
||||
title: block.display_name,
|
||||
sectionIds: block.children || [],
|
||||
hasScheduledContent: block.has_scheduled_content,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'chapter':
|
||||
models.sections[block.id] = {
|
||||
complete: block.complete,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
resumeBlock: block.resume_block,
|
||||
sequenceIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sequential':
|
||||
models.sequences[block.id] = {
|
||||
complete: block.complete,
|
||||
description: block.description,
|
||||
due: block.due,
|
||||
effortActivities: block.effort_activities,
|
||||
effortTime: block.effort_time,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
// 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;
|
||||
|
||||
default:
|
||||
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Next go through each list and use their child lists to decorate those children with a
|
||||
// reference back to their parent.
|
||||
Object.values(models.courses).forEach(course => {
|
||||
if (Array.isArray(course.sectionIds)) {
|
||||
course.sectionIds.forEach(sectionId => {
|
||||
const section = models.sections[sectionId];
|
||||
section.courseId = course.id;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(models.sections).forEach(section => {
|
||||
if (Array.isArray(section.sequenceIds)) {
|
||||
section.sequenceIds.forEach(sequenceId => {
|
||||
if (sequenceId in models.sequences) {
|
||||
models.sequences[sequenceId].sectionId = section.id;
|
||||
} else {
|
||||
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
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, rootSlug);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
// Just uncomment the next few lines and the immediate 'return' in the function below
|
||||
// import { Factory } from 'rosie';
|
||||
// import './__factories__';
|
||||
export async function getDatesTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
// return camelCaseObject(Factory.build('datesTabData'));
|
||||
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`);
|
||||
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) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
export async function getProgressTabData(courseId, targetUserId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
|
||||
|
||||
// If targetUserId is passed in, we will get the progress page data
|
||||
// for the user with the provided id, rather than the requesting user.
|
||||
if (targetUserId) {
|
||||
url += `/${targetUserId}/`;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
|
||||
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
|
||||
camelCasedData.gradingPolicy.assignmentPolicies,
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
|
||||
|
||||
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
|
||||
|
||||
camelCasedData.gradesFeatureIsPartiallyLocked = false;
|
||||
if (camelCasedData.gradesFeatureIsFullyLocked) {
|
||||
camelCasedData.sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
// If something is eligible to be gated by content type gating and would show up on the progress page
|
||||
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
|
||||
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
|
||||
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
|
||||
// since the learner has access to some (but not all) content that would normally be locked
|
||||
if (subsection.learnerHasAccess) {
|
||||
camelCasedData.gradesFeatureIsPartiallyLocked = true;
|
||||
camelCasedData.gradesFeatureIsFullyLocked = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 401) {
|
||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
return {};
|
||||
}
|
||||
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 getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
let { tabData } = {};
|
||||
export async function getProctoringInfoData(courseId, username) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
if (username) {
|
||||
url += `&username=${encodeURIComponent(username)}`;
|
||||
}
|
||||
try {
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
let timeOffsetMillis = 0;
|
||||
if (headerDate !== undefined) {
|
||||
const headerTime = Date.parse(headerDate);
|
||||
const roundTripMillis = requestTime - responseTime;
|
||||
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
|
||||
timeOffsetMillis = headerTime - localTime;
|
||||
}
|
||||
|
||||
return timeOffsetMillis;
|
||||
}
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const requestTime = Date.now();
|
||||
let tabData;
|
||||
try {
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
} catch (error) {
|
||||
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,
|
||||
} = tabData;
|
||||
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
|
||||
const courseExpiredHtml = data.course_expired_html;
|
||||
|
||||
const accessExpiration = camelCaseObject(data.access_expiration);
|
||||
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
||||
const certData = camelCaseObject(data.cert_data);
|
||||
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
||||
const courseGoals = camelCaseObject(data.course_goals);
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enableProctoredExams = data.enable_proctored_exams;
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const enrollmentMode = data.enrollment_mode;
|
||||
const handoutsHtml = data.handouts_html;
|
||||
const offerHtml = data.offer_html;
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
const hasScheduledContent = data.has_scheduled_content;
|
||||
const hasEnded = data.has_ended;
|
||||
const offer = camelCaseObject(data.offer);
|
||||
const resumeCourse = camelCaseObject(data.resume_course);
|
||||
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
||||
const userHasPassingGrade = data.user_has_passing_grade;
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html || '';
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
certData,
|
||||
courseBlocks,
|
||||
courseExpiredHtml,
|
||||
courseGoals,
|
||||
courseTools,
|
||||
datesBannerInfo,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
enrollmentMode,
|
||||
enableProctoredExams,
|
||||
handoutsHtml,
|
||||
offerHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
offer,
|
||||
resumeCourse,
|
||||
timeOffsetMillis, // This should move to a global time correction reference
|
||||
userHasPassingGrade,
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postCourseDeadlines(courseId) {
|
||||
export async function postCourseDeadlines(courseId, model) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
|
||||
return getAuthenticatedHttpClient().post(url.href, {
|
||||
course_key: courseId,
|
||||
research_event_data: { location: `${model}-tab` },
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -103,3 +431,23 @@ export async function postRequestCert(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
|
||||
await getAuthenticatedHttpClient().post(url.href);
|
||||
}
|
||||
|
||||
export async function executePostFromPostEvent(postData, researchEventData) {
|
||||
const url = new URL(postData.url);
|
||||
return getAuthenticatedHttpClient().post(url.href, {
|
||||
course_key: postData.bodyParams.courseId,
|
||||
research_event_data: researchEventData,
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
16
src/course-home/data/api.test.js
Normal file
16
src/course-home/data/api.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getTimeOffsetMillis } from './api';
|
||||
|
||||
describe('Calculate the time offset properly', () => {
|
||||
it('Should return 0 if the headerDate is not set', async () => {
|
||||
const offset = getTimeOffsetMillis(undefined, undefined, undefined);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('Should return the offset', async () => {
|
||||
const headerDate = '2021-04-13T11:01:58.135Z';
|
||||
const requestTime = new Date('2021-04-12T11:01:57.135Z');
|
||||
const responseTime = new Date('2021-04-12T11:01:58.635Z');
|
||||
const offset = getTimeOffsetMillis(headerDate, requestTime, responseTime);
|
||||
expect(offset).toBe(86398750);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ export {
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,9 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
|
||||
import executeThunk from '../../utils';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
|
||||
import initializeMockApp from '../../setupTest';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
@@ -16,21 +16,23 @@ const { loggingService } = initializeMockApp();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const courseHomeMetadata = Factory.build(
|
||||
'courseHomeMetadata', {
|
||||
course_id: courseMetadata.id,
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const { id: courseId } = courseHomeMetadata;
|
||||
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',
|
||||
},
|
||||
},
|
||||
{ courseTabs: courseMetadata.tabs },
|
||||
);
|
||||
|
||||
const courseId = courseMetadata.id;
|
||||
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
|
||||
const courseMetadataBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
|
||||
|
||||
const courseUrl = `${courseBaseUrl}/${courseId}`;
|
||||
const courseMetadataUrl = `${courseMetadataBaseUrl}/${courseId}`;
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,15 +42,10 @@ describe('Data layer integration tests', () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('Should initialize store', () => {
|
||||
expect(store.getState()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
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(courseUrl).networkError();
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
@@ -63,7 +60,6 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const datesUrl = `${datesBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
|
||||
@@ -71,17 +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(courseUrl).networkError();
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
axiosMock.onGet(outlineUrl).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
@@ -92,9 +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(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
@@ -102,23 +118,122 @@ 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/progress`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${progressBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const progressTabData = Factory.build('progressTabData', { courseId });
|
||||
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot({
|
||||
// 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 () => {
|
||||
const progressTabData = Factory.build('progressTabData', { courseId });
|
||||
const targetUserId = 2;
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.targetUserId).toEqual(2);
|
||||
});
|
||||
|
||||
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/save_course_goal`;
|
||||
axiosMock.onPost(goalUrl).reply(200, {});
|
||||
|
||||
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"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test resetDeadlines', () => {
|
||||
it('Should reset course deadlines', async () => {
|
||||
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
|
||||
axiosMock.onPost(resetUrl).reply(201);
|
||||
const model = 'dates';
|
||||
axiosMock.onPost(resetUrl).reply(201, {});
|
||||
|
||||
const getTabDataMock = jest.fn(() => ({
|
||||
type: 'MOCK_ACTION',
|
||||
}));
|
||||
|
||||
await executeThunk(thunks.resetDeadlines(courseId, getTabDataMock), store.dispatch);
|
||||
await executeThunk(thunks.resetDeadlines(courseId, model, getTabDataMock), store.dispatch);
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}"}`);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}","research_event_data":{"location":"dates-tab"}}`);
|
||||
|
||||
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
|
||||
});
|
||||
@@ -126,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);
|
||||
@@ -135,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,38 +4,64 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'course-home',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
displayResetDatesToast: false,
|
||||
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;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
fetchTabRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
},
|
||||
fetchTabSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.targetUserId = payload.targetUserId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
setCallToActionToast: (state, { payload }) => {
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
linkText,
|
||||
} = payload;
|
||||
state.toastBodyLink = link;
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
toggleResetDatesToast: (state, { payload }) => {
|
||||
state.displayResetDatesToast = payload.displayResetDatesToast;
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchProctoringInfoResolved,
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
toggleResetDatesToast,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
executePostFromPostEvent,
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
deprecatedPostCourseGoals,
|
||||
postWeeklyLearningGoal,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
getCoursewareSearchEnabledFlag,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
@@ -14,52 +20,52 @@ import {
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
toggleResetDatesToast,
|
||||
setCallToActionToast,
|
||||
} from './slice';
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData) {
|
||||
const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courses',
|
||||
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);
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (tabDataResult || !getTabData) {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
targetUserId,
|
||||
}));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
logError(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,21 +73,20 @@ export function fetchDatesTab(courseId) {
|
||||
return fetchTab(courseId, 'dates', getDatesTabData);
|
||||
}
|
||||
|
||||
export function fetchProgressTab(courseId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData);
|
||||
export function fetchProgressTab(courseId, targetUserId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
|
||||
}
|
||||
|
||||
export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function resetDeadlines(courseId, getTabData) {
|
||||
return async (dispatch) => {
|
||||
postCourseDeadlines(courseId).then(() => {
|
||||
dispatch(getTabData(courseId));
|
||||
dispatch(toggleResetDatesToast({ displayResetDatesToast: true }));
|
||||
});
|
||||
};
|
||||
export function fetchLiveTab(courseId) {
|
||||
return fetchTab(courseId, 'live', getLiveTabIframe);
|
||||
}
|
||||
|
||||
export function fetchDiscussionTab(courseId) {
|
||||
return fetchTab(courseId, 'discussion');
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
@@ -91,3 +96,56 @@ export function dismissWelcomeMessage(courseId) {
|
||||
export function requestCert(courseId) {
|
||||
return async () => postRequestCert(courseId);
|
||||
}
|
||||
|
||||
export function resetDeadlines(courseId, model, getTabData) {
|
||||
return async (dispatch) => {
|
||||
postCourseDeadlines(courseId, model).then(response => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
link_text: linkText,
|
||||
} = data;
|
||||
dispatch(getTabData(courseId));
|
||||
dispatch(setCallToActionToast({ header, link, linkText }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
|
||||
return deprecatedPostCourseGoals(courseId, goalKey);
|
||||
}
|
||||
|
||||
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
||||
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
|
||||
}
|
||||
|
||||
export function processEvent(eventData, getTabData) {
|
||||
return async (dispatch) => {
|
||||
// Pulling this out early so the data doesn't get camelCased and is easier
|
||||
// to use when it's passed to the backend
|
||||
const { research_event_data: researchEventData } = eventData;
|
||||
const event = camelCaseObject(eventData);
|
||||
if (event.eventName === eventTypes.POST_EVENT) {
|
||||
executePostFromPostEvent(event.postData, researchEventData).then(response => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
link_text: linkText,
|
||||
} = data;
|
||||
dispatch(getTabData(event.postData.bodyParams.courseId));
|
||||
dispatch(setCallToActionToast({ header, link, linkText }));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCoursewareSearchSettings(courseId) {
|
||||
try {
|
||||
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
|
||||
return { enabled };
|
||||
} catch (e) {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function DatesBanner(props) {
|
||||
const {
|
||||
intl,
|
||||
name,
|
||||
bannerClickHandler,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
|
||||
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
|
||||
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-md-9'}>
|
||||
<strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
|
||||
</strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
|
||||
</div>
|
||||
{bannerClickHandler && (
|
||||
<div className="col-auto col-md-3 p-md-0 d-inline-flex align-items-center justify-content-start justify-content-md-center">
|
||||
<button type="button" className="btn rounded align-self-center border border-primary bg-white mt-3 mt-md-0 font-weight-bold" onClick={bannerClickHandler}>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
bannerClickHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
DatesBanner.defaultProps = {
|
||||
bannerClickHandler: null,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesBanner);
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import DatesBanner from './DatesBanner';
|
||||
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
|
||||
|
||||
function DatesBannerContainer(props) {
|
||||
const {
|
||||
model,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
|
||||
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
|
||||
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
|
||||
const datesBanners = [
|
||||
{
|
||||
name: 'datesTabInfoBanner',
|
||||
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
|
||||
},
|
||||
{
|
||||
name: 'upgradeToCompleteGradedBanner',
|
||||
shouldDisplay: upgradeToCompleteGraded,
|
||||
clickHandler: () => window.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'upgradeToResetBanner',
|
||||
shouldDisplay: upgradeToReset,
|
||||
clickHandler: () => window.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'resetDatesBanner',
|
||||
shouldDisplay: resetDates,
|
||||
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
|
||||
<DatesBanner
|
||||
name={banner.name}
|
||||
bannerClickHandler={banner.clickHandler}
|
||||
key={banner.name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBannerContainer.propTypes = {
|
||||
model: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default DatesBannerContainer;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DatesBannerContainer from './DatesBannerContainer';
|
||||
|
||||
export default DatesBannerContainer;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'datesBanner.datesTabInfoBanner.header': {
|
||||
id: 'datesBanner.datesTabInfoBanner.header',
|
||||
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
|
||||
description: 'Strong text in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.datesTabInfoBanner.body': {
|
||||
id: 'datesBanner.datesTabInfoBanner.body',
|
||||
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
|
||||
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
|
||||
description: 'Body in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.header': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.body': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
|
||||
assignments as part of this course, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.button': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'Button in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.header': {
|
||||
id: 'datesBanner.upgradeToResetBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.body': {
|
||||
id: 'datesBanner.upgradeToResetBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
|
||||
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
|
||||
and shift the past due assignments into the future, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.button': {
|
||||
id: 'datesBanner.upgradeToResetBanner.button',
|
||||
defaultMessage: 'Upgrade to shift due dates',
|
||||
description: 'Button in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.header': {
|
||||
id: 'datesBanner.resetDatesBanner.header',
|
||||
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
|
||||
description: 'Strong text in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.body': {
|
||||
id: 'datesBanner.resetDatesBanner.body',
|
||||
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
|
||||
the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.`,
|
||||
description: 'Body in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.button': {
|
||||
id: 'datesBanner.resetDatesBanner.button',
|
||||
defaultMessage: 'Shift due dates',
|
||||
description: 'Button in Reset Dates Banner',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function Badge({ children, className }) {
|
||||
return (
|
||||
<span className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Badge.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Badge.defaultProps = {
|
||||
children: null,
|
||||
className: null,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
.dates-badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -1,21 +1,63 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import Timeline from './Timeline';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
import Timeline from './timeline/Timeline';
|
||||
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
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';
|
||||
|
||||
const DatesTab = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'dates_upgrade',
|
||||
linkType: 'button',
|
||||
pageName: 'dates_tab',
|
||||
});
|
||||
};
|
||||
|
||||
function DatesTab({ intl }) {
|
||||
return (
|
||||
<>
|
||||
<div role="heading" aria-level="1" className="h4 my-3">
|
||||
<div role="heading" aria-level="1" className="h2 my-3">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
<DatesBannerContainer model="dates" />
|
||||
{isSelfPaced && hasDeadlines && (
|
||||
<>
|
||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||
<SuggestedScheduleHeader />
|
||||
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} />
|
||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||
</>
|
||||
)}
|
||||
<Timeline />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DatesTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
357
src/course-home/dates-tab/DatesTab.test.jsx
Normal file
357
src/course-home/dates-tab/DatesTab.test.jsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { waitForElementToBeRemoved } from '@testing-library/dom';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import DatesTab from './DatesTab';
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { fireEvent, initializeMockApp, waitFor } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { TabContainer } from '../../tab-page';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('DatesTab', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<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', { user_timezone: 'America/New_York' });
|
||||
const { id: courseId } = courseMetadata;
|
||||
|
||||
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', attributes, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
|
||||
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
|
||||
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
|
||||
// better than assuming anything about how the surrounding elements are organized by div and span or whatever. And
|
||||
// better than adding non-style class names.
|
||||
// Hence the following getDay query helper.
|
||||
async function getDay(date) {
|
||||
const dateNode = await screen.findByText(date);
|
||||
let parent = dateNode.parentElement;
|
||||
while (parent) {
|
||||
if (parent.dataset && parent.dataset.testid === 'dates-day') {
|
||||
return {
|
||||
day: parent,
|
||||
header: within(parent).getByTestId('dates-header'),
|
||||
items: within(parent).queryAllByTestId('dates-item'),
|
||||
};
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
throw new Error('Did not find day container');
|
||||
}
|
||||
|
||||
describe('when receiving a full set of dates data', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
|
||||
it('handles unreleased & complete', async () => {
|
||||
const { header } = await getDay('Sun, May 3, 2020');
|
||||
const badges = within(header).getAllByTestId('dates-badge');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('Completed');
|
||||
expect(badges[1]).toHaveTextContent('Not yet released');
|
||||
});
|
||||
|
||||
it('handles unreleased & past due', async () => {
|
||||
const { header } = await getDay('Mon, May 4, 2020');
|
||||
const badges = within(header).getAllByTestId('dates-badge');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('Past due');
|
||||
expect(badges[1]).toHaveTextContent('Not yet released');
|
||||
});
|
||||
|
||||
it('handles verified only', async () => {
|
||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||
const badge = within(day).getByTestId('dates-badge');
|
||||
expect(badge).toHaveTextContent('Verified only');
|
||||
});
|
||||
|
||||
it('verified only has no link', async () => {
|
||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||
expect(within(day).queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('same status items have header badge', async () => {
|
||||
const { day, header } = await getDay('Tue, May 26, 2020');
|
||||
const badge = within(header).getByTestId('dates-badge');
|
||||
expect(badge).toHaveTextContent('Past due'); // one header badge
|
||||
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
|
||||
});
|
||||
|
||||
it('different status items have individual badges', async () => {
|
||||
const { header, items } = await getDay('Thu, May 28, 2020');
|
||||
const headerBadges = within(header).queryAllByTestId('dates-badge');
|
||||
expect(headerBadges).toHaveLength(0); // no header badges
|
||||
expect(items).toHaveLength(2);
|
||||
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
|
||||
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past due');
|
||||
});
|
||||
|
||||
it('shows extra info', async () => {
|
||||
const { items } = await getDay('Sat, Aug 17, 2030');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
const tipIcon = within(items[2]).getByTestId('dates-extra-info');
|
||||
const tipText = "ORA Dates are set by the instructor, and can't be changed";
|
||||
|
||||
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suggested schedule messaging', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({ is_self_paced: true, is_enrolled: true });
|
||||
history.push(`/course/${courseId}/dates`);
|
||||
});
|
||||
|
||||
it('renders SuggestedScheduleHeader', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: false,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('We’ve built a suggested schedule to help you stay on track. But don’t worry—it’s flexible so you can learn at your own pace.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders UpgradeToCompleteAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders UpgradeToShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: true,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles shift due dates click', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
// confirm "Shift due dates" button has rendered
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument());
|
||||
|
||||
// update response to reflect shifted dates
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
const resetDeadlinesData = {
|
||||
header: "You've successfully shifted your dates!",
|
||||
};
|
||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`).reply(200, resetDeadlinesData);
|
||||
|
||||
// click "Shift due dates"
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Shift due dates' }));
|
||||
|
||||
// wait for page to reload & Toast to render
|
||||
await waitFor(() => expect(screen.getByText("You've successfully shifted your dates!")).toBeInTheDocument());
|
||||
// confirm "Shift due dates" button has not rendered
|
||||
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'dates_upgrade',
|
||||
linkType: 'button',
|
||||
pageName: 'dates_tab',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
missedGatedContent: true,
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
linkName: 'dates_upgrade',
|
||||
linkType: 'button',
|
||||
pageName: 'dates_tab',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when receiving an access denied error', () => {
|
||||
// These tests could go into any particular tab, as they all go through the same flow. But dates tab works.
|
||||
|
||||
async function renderDenied(errorCode) {
|
||||
setMetadata({
|
||||
course_access: {
|
||||
has_access: false,
|
||||
error_code: errorCode,
|
||||
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
|
||||
},
|
||||
});
|
||||
render(component);
|
||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
});
|
||||
|
||||
it('redirects to course survey for a survey_required error code', async () => {
|
||||
await renderDenied('survey_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('redirects to dashboard for an unfulfilled_milestones error code', async () => {
|
||||
await renderDenied('unfulfilled_milestones');
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
|
||||
});
|
||||
|
||||
it('redirects to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
|
||||
await renderDenied('audit_expired');
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
|
||||
});
|
||||
|
||||
it('redirects to the dashboard with a notlive start date for a course_not_started error code', async () => {
|
||||
await renderDenied('course_not_started');
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?notlive=2/5/2013'); // date from factory
|
||||
});
|
||||
|
||||
it('redirects to the home page when unauthenticated', async () => {
|
||||
await renderDenied('authentication_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
|
||||
it('redirects to the home page when unenrolled', async () => {
|
||||
await renderDenied('enrollment_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
// Sample data helpful when developing, to see a variety of configurations.
|
||||
// This set of data is not realistic (mix of having access and not), but it
|
||||
// is intended to demonstrate many UI results.
|
||||
// To use, have getDatesTabData in api.js return the result of this call instead:
|
||||
/*
|
||||
import fakeDatesData from '../dates-tab/fakeData';
|
||||
export async function getDatesTabData(courseId, version) {
|
||||
if (tab === 'dates') { return camelCaseObject(fakeDatesData()); }
|
||||
...
|
||||
}
|
||||
*/
|
||||
|
||||
export default function fakeDatesData() {
|
||||
return JSON.parse(`
|
||||
{
|
||||
"course_date_blocks": [
|
||||
{
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"date_type": "course-start-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Multi Badges Completed",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"date_type": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
"extra_info": "ORA Dates are set by the instructor, and can't be changed"
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "One Unreleased 1"
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Both Unreleased 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Both Unreleased 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"date_type": "course-end-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"date_type": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Verification Deadline",
|
||||
"extra_info": null
|
||||
}
|
||||
],
|
||||
"display_reset_dates_text": false,
|
||||
"learner_is_verified": false,
|
||||
"user_timezone": "America/New_York",
|
||||
"verified_upgrade_link": "https://example.com/"
|
||||
}
|
||||
`);
|
||||
}
|
||||
@@ -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',
|
||||
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',
|
||||
defaultMessage: 'Past due',
|
||||
description: 'Shown as label for the assignments which deadline has passed',
|
||||
},
|
||||
title: {
|
||||
id: 'learning.dates.title',
|
||||
defaultMessage: 'Important Dates',
|
||||
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',
|
||||
defaultMessage: 'Not yet released',
|
||||
description: 'Shown as label for assignments which date is unknown yet',
|
||||
},
|
||||
verifiedOnly: {
|
||||
id: 'learning.dates.badge.verifiedOnly',
|
||||
defaultMessage: 'Verified Only',
|
||||
defaultMessage: 'Verified only',
|
||||
description: 'Shown as label for assignments which learner has no access to.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,29 +2,41 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedTime,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Tooltip, OverlayTrigger } from '@edx/paragon';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from './utils';
|
||||
import { isLearnerAssignment } from '../utils';
|
||||
|
||||
function Day({
|
||||
date, first, intl, items, last,
|
||||
}) {
|
||||
const Day = ({
|
||||
date,
|
||||
first,
|
||||
intl,
|
||||
items,
|
||||
last,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<li className="dates-day pb-4">
|
||||
<li className="dates-day pb-4" data-testid="dates-day">
|
||||
{/* Top Line */}
|
||||
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
|
||||
|
||||
@@ -36,42 +48,62 @@ function Day({
|
||||
|
||||
{/* Content */}
|
||||
<div className="d-inline-block ml-3 pl-2">
|
||||
<div className="mb-1">
|
||||
<p className="d-inline text-dark-500 font-weight-bold">
|
||||
<FormattedDate
|
||||
value={date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
|
||||
<FormattedDate
|
||||
value={date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
{badges}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
|
||||
|
||||
const showDueDateTime = item.dateType === 'assignment-due-date';
|
||||
const showLink = item.link && isLearnerAssignment(item);
|
||||
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
|
||||
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
|
||||
const textColor = available ? 'text-dark-500' : 'text-dark-200';
|
||||
const textColor = available ? 'text-primary-700' : 'text-gray-500';
|
||||
|
||||
return (
|
||||
<div key={item.title + item.date} className={textColor}>
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
|
||||
<div>
|
||||
<span className="font-weight-bold small mt-1">
|
||||
{item.assignmentType && `${item.assignmentType}: `}{title}
|
||||
<span className="small">
|
||||
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
|
||||
{showDueDateTime && (
|
||||
<span>
|
||||
<span className="mx-1">due</span>
|
||||
<FormattedTime
|
||||
value={date}
|
||||
timeZoneName="short"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{itemBadges}
|
||||
{item.extraInfo && (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Tooltip>{item.extraInfo}</Tooltip>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" data-testid="dates-extra-info" />
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <div className="small mb-2">{item.description}</div>}
|
||||
{item.extraInfo && <div className="small mb-2">{item.extraInfo}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Day.propTypes = {
|
||||
date: PropTypes.objectOf(Date).isRequired,
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import Day from './Day';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
export default function Timeline() {
|
||||
const Timeline = () => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -61,10 +61,12 @@ export default function Timeline() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled m-0">
|
||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Badge } from '@edx/paragon';
|
||||
|
||||
import Badge from './Badge';
|
||||
import messages from './messages';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
import messages from '../messages';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
function hasAccess(item) {
|
||||
return item.learnerHasAccess;
|
||||
@@ -37,19 +37,22 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
{
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'dates-bg-today',
|
||||
bg: 'bg-warning-300',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-dark-100',
|
||||
bg: 'bg-light-500',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||
bg: 'bg-dark-200',
|
||||
className: 'text-white',
|
||||
},
|
||||
{
|
||||
message: messages.dueNext,
|
||||
@@ -62,14 +65,14 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
message: messages.unreleased,
|
||||
shownForDay: assignments.length && assignments.every(isUnreleased),
|
||||
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
|
||||
className: 'border border-dark-200 text-gray-500 align-top',
|
||||
className: 'border border-gray-500 text-gray-500',
|
||||
},
|
||||
{
|
||||
message: messages.verifiedOnly,
|
||||
shownForDay: items.length && items.every(x => !hasAccess(x)),
|
||||
shownForItem: x => !hasAccess(x),
|
||||
icon: faLock,
|
||||
bg: 'bg-dark-500',
|
||||
bg: 'bg-dark-700',
|
||||
className: 'text-white',
|
||||
},
|
||||
];
|
||||
@@ -93,7 +96,7 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
color = b.bg;
|
||||
}
|
||||
return (
|
||||
<Badge key={b.message.id} className={classNames(b.bg, b.className)}>
|
||||
<Badge key={b.message.id} className={classNames('ml-2', b.bg, b.className)} data-testid="dates-badge">
|
||||
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
|
||||
{intl.formatMessage(b.message)}
|
||||
</Badge>
|
||||
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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user