Compare commits
40 Commits
AA-820b
...
mikix/mfe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d67042660 | ||
|
|
915f521976 | ||
|
|
903d8d4cfb | ||
|
|
f2f4f5f3a5 | ||
|
|
28d359e715 | ||
|
|
d93df0e06f | ||
|
|
86a4cf9af7 | ||
|
|
e423dddb03 | ||
|
|
f21dad95b5 | ||
|
|
9978ddf418 | ||
|
|
d2a8d870af | ||
|
|
3ef4daecce | ||
|
|
d2573a16b1 | ||
|
|
e7c0ebdfe3 | ||
|
|
1ad2cf73bf | ||
|
|
d60d782e5a | ||
|
|
83151d291c | ||
|
|
ed55920f99 | ||
|
|
4f1c8a4671 | ||
|
|
0878bf9f13 | ||
|
|
21f3875dae | ||
|
|
d4dc75c5a0 | ||
|
|
8a1151e8c5 | ||
|
|
5e925c93da | ||
|
|
43033ddc91 | ||
|
|
cf08fa5eb9 | ||
|
|
37ce01c00a | ||
|
|
9edac2519a | ||
|
|
f9fbc1eb49 | ||
|
|
5c68c1d554 | ||
|
|
4180a2e7a0 | ||
|
|
554e63d653 | ||
|
|
c9f299eada | ||
|
|
d1bb46eef3 | ||
|
|
492c573f56 | ||
|
|
0c55863a3d | ||
|
|
fbd9d858e4 | ||
|
|
7b0429f472 | ||
|
|
56decd8ed0 | ||
|
|
3155055276 |
168
package-lock.json
generated
168
package-lock.json
generated
@@ -1329,39 +1329,47 @@
|
||||
}
|
||||
},
|
||||
"@edx/frontend-component-footer": {
|
||||
"version": "10.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-10.1.4.tgz",
|
||||
"integrity": "sha512-oRgK9IBg9J4LGYGLkoeRO5X5G9MeSt37uSvZ998BBEIp9fVx8gBMwd6COEJVN8sgZqO/ckFbBOlZXA/7iDWOEw==",
|
||||
"version": "10.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-10.1.5.tgz",
|
||||
"integrity": "sha512-QoQCiR7AYKBD2jx4zQQa+XVPbv7aNC0b1FdFz9GMEcswp8kpsZsupfb76Pp3Z2hiDURj63LpwhUyv1B0zqwVAA==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.8.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.8.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.8.2",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.35",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.3",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.3",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.3",
|
||||
"@fortawesome/react-fontawesome": "0.1.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-brands-svg-icons": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.8.2.tgz",
|
||||
"integrity": "sha512-nhEWctDOP6f+Ka10LXAFoF+6mtWidC2iQgTBGRGgydmhBtcIEwyxWVx5wQHa86A1zAMi5TnipDAYQs2qn7DD6A==",
|
||||
"@fortawesome/fontawesome-svg-core": {
|
||||
"version": "1.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.35.tgz",
|
||||
"integrity": "sha512-uLEXifXIL7hnh2sNZQrIJWNol7cTVIzwI+4qcBIq9QWaZqUblm0IDrtSqbNg+3SQf8SMGHkiSigD++rHmCHjBg==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.18"
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.35"
|
||||
}
|
||||
},
|
||||
"@fortawesome/free-brands-svg-icons": {
|
||||
"version": "5.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.3.tgz",
|
||||
"integrity": "sha512-1hirPcbjj72ZJtFvdnXGPbAbpn3Ox6mH3g5STbANFp3vGSiE5u5ingAKV06mK6ZVqNYxUPlh4DlTnaIvLtF2kw==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.35"
|
||||
}
|
||||
},
|
||||
"@fortawesome/free-regular-svg-icons": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.8.2.tgz",
|
||||
"integrity": "sha512-499WODlsDcXA9hM+Y3ZqoWj1kKhVLCHS8PnJs3zEaoPr5W6yrE9Jnp3C9Hb9KpwnRMh3IRXZ3XqxyUaQFMMRFw==",
|
||||
"version": "5.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.3.tgz",
|
||||
"integrity": "sha512-q4/p8Xehy9qiVTdDWHL4Z+o5PCLRChePGZRTXkl+/Z7erDVL8VcZUuqzJjs6gUz6czss4VIPBRdCz6wP37/zMQ==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.18"
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.35"
|
||||
}
|
||||
},
|
||||
"@fortawesome/free-solid-svg-icons": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.8.2.tgz",
|
||||
"integrity": "sha512-5tF6WOFlqqO95zY5ukSB6jliDa3jnk1p5L4K/a58ccDFsbjSkhfGuvZkRkeWxH8uMms81pZd6yQTwQrkedeJmg==",
|
||||
"version": "5.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.3.tgz",
|
||||
"integrity": "sha512-XPeeu1IlGYqz4VWGRAT5ukNMd4VHUEEJ7ysZ7pSSgaEtNvSo+FLurybGJVmiqkQdK50OkSja2bfZXOeyMGRD8Q==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.18"
|
||||
"@fortawesome/fontawesome-common-types": "^0.2.35"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1375,9 +1383,9 @@
|
||||
}
|
||||
},
|
||||
"@edx/frontend-lib-special-exams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-1.0.0.tgz",
|
||||
"integrity": "sha512-tVF38zD1+madCvxFb0HT3WEfGIPyQaaMr64y7bA/JMkPR34o8Zz6PibhNymmQESElNWZtavLdYxX5wCRIIhTxA==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-1.9.0.tgz",
|
||||
"integrity": "sha512-dG9iW/vru/6Rh9+1kkqDu3gdiSVF49/9JvdjPb+7H1mdZPs4/FdP43HicPgaJ4r6PKfd9Ssam+3rq1NhxpWzvw==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
@@ -1439,9 +1447,9 @@
|
||||
}
|
||||
},
|
||||
"@edx/paragon": {
|
||||
"version": "14.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-14.8.0.tgz",
|
||||
"integrity": "sha512-ZCT4bur0ZlwI+UrzYcSRU0Vo9rBbSszbXrCrCeA5aZV9/xiwjoVJcMGxhWAMEfk5n9/R/bXBakcxc2Z+PjBEaQ==",
|
||||
"version": "15.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-15.2.2.tgz",
|
||||
"integrity": "sha512-C4YMd4zjRalS8pPglpAvDnribrA/3x8XXcbyTq0Xwwotp9HSld2yndASczZGdjNcqG0b1gpmPdxkzx2kaogCiw==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||
@@ -1452,6 +1460,7 @@
|
||||
"classnames": "^2.2.6",
|
||||
"email-prop-type": "^3.0.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"lodash.uniqby": "^4.7.0",
|
||||
"mailto-link": "^1.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-bootstrap": "^1.3.0",
|
||||
@@ -1461,7 +1470,7 @@
|
||||
"react-responsive": "^6.1.1",
|
||||
"react-table": "^7.6.1",
|
||||
"react-transition-group": "^4.0.0",
|
||||
"sanitize-html": "^1.20.0",
|
||||
"sanitize-html": "^1.27.5",
|
||||
"tabbable": "^4.0.0",
|
||||
"uncontrollable": "7.2.1"
|
||||
},
|
||||
@@ -2354,12 +2363,11 @@
|
||||
"integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q=="
|
||||
},
|
||||
"@restart/hooks": {
|
||||
"version": "0.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.26.tgz",
|
||||
"integrity": "sha512-7Hwk2ZMYm+JLWcb7R9qIXk1OoUg1Z+saKWqZXlrvFwT3w6UArVNWgxYOzf+PJoK9zZejp8okPAKTctthhXLt5g==",
|
||||
"version": "0.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.27.tgz",
|
||||
"integrity": "sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.20",
|
||||
"lodash-es": "^4.17.20"
|
||||
"dequal": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"@sindresorhus/is": {
|
||||
@@ -2912,14 +2920,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/classnames": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz",
|
||||
"integrity": "sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A==",
|
||||
"requires": {
|
||||
"classnames": "*"
|
||||
}
|
||||
},
|
||||
"@types/cookie": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz",
|
||||
@@ -6933,6 +6933,11 @@
|
||||
"integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
|
||||
"dev": true
|
||||
},
|
||||
"dequal": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz",
|
||||
"integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug=="
|
||||
},
|
||||
"des.js": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
|
||||
@@ -7080,12 +7085,12 @@
|
||||
}
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz",
|
||||
"integrity": "sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz",
|
||||
"integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.0.0",
|
||||
"domhandler": "^4.2.0",
|
||||
"entities": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -7136,9 +7141,9 @@
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.6.0.tgz",
|
||||
"integrity": "sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.7.0.tgz",
|
||||
"integrity": "sha512-8eaHa17IwJUPAiB+SoTYBo5mCdeMgdcAoXJ59m6DT1vw+5iLS3gNoqYaRowaBKtGVrOF1Jz4yDTgYKLK2kvfJg==",
|
||||
"requires": {
|
||||
"dom-serializer": "^1.0.1",
|
||||
"domelementtype": "^2.2.0",
|
||||
@@ -8849,11 +8854,18 @@
|
||||
}
|
||||
},
|
||||
"focus-lock": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.8.1.tgz",
|
||||
"integrity": "sha512-/LFZOIo82WDsyyv7h7oc0MJF9ACOvDRdx9rWPZ2pgMfNWu/z8hQDBtOchuB/0BVLmuFOZjV02YwUVzNsWx/EzA==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.9.1.tgz",
|
||||
"integrity": "sha512-/2Nj60Cps6yOLSO+CkVbeSKfwfns5XbX6HOedIK9PdzODP04N9c3xqOcPXayN0WsT9YjJvAnXmI0NdqNIDf5Kw==",
|
||||
"requires": {
|
||||
"tslib": "^1.9.3"
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
@@ -14174,11 +14186,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
|
||||
},
|
||||
"lodash.assignin": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
|
||||
@@ -14290,6 +14297,11 @@
|
||||
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.uniqby": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
|
||||
"integrity": "sha1-2ZwHpmnp5tJOE2Lf4mbGdhavEwI="
|
||||
},
|
||||
"logalot": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/logalot/-/logalot-2.1.0.tgz",
|
||||
@@ -17230,28 +17242,34 @@
|
||||
}
|
||||
},
|
||||
"react-bootstrap": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.0.tgz",
|
||||
"integrity": "sha512-PaeOGeRC2+JH9Uf1PukJgXcIpfGlrKKHEBZIArymjenYzSJ/RhO2UdNX+e7nalsCFFZLRRgQ0/FKkscW2LmmRg==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.1.tgz",
|
||||
"integrity": "sha512-ojEPQ6OtyIMdLg0Smofk+85PKN6MLKQX3bU0Vwmok/4yNa8DQ2vCGhO2IgHJvT+ERQZ4X+gAQcdn6msAHSwLBg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.8",
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"@restart/context": "^2.1.4",
|
||||
"@restart/hooks": "^0.3.26",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/invariant": "^2.2.33",
|
||||
"@types/prop-types": "^15.7.3",
|
||||
"@types/react": ">=16.9.35",
|
||||
"@types/react": ">=16.14.8",
|
||||
"@types/react-transition-group": "^4.4.1",
|
||||
"@types/warning": "^3.0.0",
|
||||
"classnames": "^2.2.6",
|
||||
"dom-helpers": "^5.1.2",
|
||||
"classnames": "^2.3.1",
|
||||
"dom-helpers": "^5.2.1",
|
||||
"invariant": "^2.2.4",
|
||||
"prop-types": "^15.7.2",
|
||||
"prop-types-extra": "^1.1.0",
|
||||
"react-overlays": "^5.0.0",
|
||||
"react-overlays": "^5.0.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"uncontrollable": "^7.2.1",
|
||||
"warning": "^4.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
|
||||
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-break": {
|
||||
@@ -17603,12 +17621,12 @@
|
||||
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
||||
},
|
||||
"react-focus-lock": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.5.0.tgz",
|
||||
"integrity": "sha512-XLxj6uTXgz0US8TmqNU2jMfnXwZG0mH2r/afQqvPEaX6nyEll5LHVcEXk2XDUQ34RVeLPkO/xK5x6c/qiuSq/A==",
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.5.1.tgz",
|
||||
"integrity": "sha512-gOToRZKVEymGEjFaTRUKgJsdYQrNosoiK7yZnXnnd8bYew4vMzk3Rxb0Q4nyrGwsFuUmgQiSAulQirA0J+v4hA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"focus-lock": "^0.8.1",
|
||||
"focus-lock": "^0.9.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"react-clientside-effect": "^1.2.2",
|
||||
"use-callback-ref": "^1.2.1",
|
||||
@@ -17669,9 +17687,9 @@
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"react-overlays": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.0.1.tgz",
|
||||
"integrity": "sha512-plwUJieTBbLSrgvQ4OkkbTD/deXgxiJdNuKzo6n1RWE3OVnQIU5hffCGS/nvIuu6LpXFs2majbzaXY8rcUVdWA==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.1.0.tgz",
|
||||
"integrity": "sha512-Qp8dqDIIYgQoHxOGVKHwvQUkDe70/Ja/6dn8iCQAXyPvvpks3+T8scLTZLK8MPBBu+X8ustas6y4U6M6zdmCjA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.8",
|
||||
"@popperjs/core": "^2.8.6",
|
||||
@@ -17802,9 +17820,9 @@
|
||||
"integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
|
||||
"integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==",
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
|
||||
"integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.1.4",
|
||||
"@edx/frontend-component-footer": "10.1.5",
|
||||
"@edx/frontend-enterprise": "4.2.3",
|
||||
"@edx/frontend-lib-special-exams": "1.0.0",
|
||||
"@edx/frontend-lib-special-exams": "1.9.0",
|
||||
"@edx/frontend-platform": "1.11.0",
|
||||
"@edx/paragon": "14.8.0",
|
||||
"@edx/paragon": "15.2.2",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Hyperlink } from '@edx/paragon';
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
|
||||
import AccessExpirationAlertMasquerade from './AccessExpirationAlertMasquerade';
|
||||
|
||||
function AccessExpirationAlert({ intl, payload }) {
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -42,24 +43,7 @@ function AccessExpirationAlert({ intl, payload }) {
|
||||
|
||||
if (masqueradingExpiredCourse) {
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.expired"
|
||||
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationExpiredDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
<AccessExpirationAlertMasquerade payload={payload} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
function AccessExpirationAlertMasquerade({ payload }) {
|
||||
const {
|
||||
accessExpiration,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
masqueradingExpiredCourse,
|
||||
} = accessExpiration;
|
||||
|
||||
if (!masqueradingExpiredCourse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.expired"
|
||||
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationExpiredDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlertMasquerade.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlertMasquerade;
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
const AccessExpirationAlertMasquerade = React.lazy(() => import('./AccessExpirationAlertMasquerade'));
|
||||
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
@@ -22,4 +23,20 @@ function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone,
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
export function useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, topic) {
|
||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
||||
const payload = {
|
||||
accessExpiration,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlertMasquerade',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic,
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlertMasquerade: AccessExpirationAlertMasquerade };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from './hooks';
|
||||
export { default, useAccessExpirationAlertMasquerade } from './hooks';
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
import { FormattedPricing } from '../../generic/upgrade-button';
|
||||
import messages from './messages';
|
||||
|
||||
function OfferAlert({ intl, payload }) {
|
||||
const {
|
||||
analyticsPageName,
|
||||
courseId,
|
||||
offer,
|
||||
org,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
if (!offer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
code,
|
||||
expirationDate,
|
||||
percentage,
|
||||
upgradeUrl,
|
||||
} = offer;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'welcome',
|
||||
linkName: `${analyticsPageName}_welcome`,
|
||||
linkType: 'link',
|
||||
pageName: analyticsPageName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.offer.header"
|
||||
defaultMessage="Upgrade by {date} and save {percentage}% [{fullPricing}]"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="offerDate"
|
||||
day="numeric"
|
||||
month="long"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
fullPricing: <FormattedPricing offer={offer} />,
|
||||
percentage,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.offer.code"
|
||||
defaultMessage="Use code {code} at checkout!"
|
||||
values={{
|
||||
code: (<b>{code}</b>),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
OfferAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
offer: PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
discountedPrice: PropTypes.string.isRequired,
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
originalPrice: PropTypes.string.isRequired,
|
||||
percentage: PropTypes.number.isRequired,
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
analyticsPageName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OfferAlert);
|
||||
@@ -1,25 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
|
||||
export function useOfferAlert(courseId, offer, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = !!offer; // if it exists, show it.
|
||||
const payload = {
|
||||
analyticsPageName,
|
||||
courseId,
|
||||
offer,
|
||||
org,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientOfferAlert',
|
||||
topic,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
});
|
||||
|
||||
return { clientOfferAlert: OfferAlert };
|
||||
}
|
||||
|
||||
export default useOfferAlert;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.offer.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -2,4 +2,4 @@ import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
import './progressTabData.factory';
|
||||
import './upgradeCardData.factory';
|
||||
import './upgradeNotificationData.factory';
|
||||
|
||||
@@ -29,6 +29,7 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
cert_data: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('upgradeCardData')
|
||||
Factory.define('upgradeNotificationData')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
@@ -5,6 +5,8 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -300,6 +302,8 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
@@ -378,6 +382,7 @@ Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"effortActivities": undefined,
|
||||
"effortTime": undefined,
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
@@ -446,6 +451,7 @@ Object {
|
||||
},
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
@@ -476,6 +482,8 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"gradesFeatureIsLocked": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
|
||||
@@ -116,6 +116,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
id: courseId,
|
||||
title: block.display_name,
|
||||
sectionIds: block.children || [],
|
||||
hasScheduledContent: block.has_scheduled_content,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -181,6 +182,26 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
return models;
|
||||
}
|
||||
|
||||
function processTabDataErrorRedirect(error) {
|
||||
const { httpErrorResponseData, httpErrorStatus } = error && error.customAttributes;
|
||||
|
||||
if (httpErrorStatus === 403) {
|
||||
// Currently, only 403 errors contain redirect content
|
||||
try {
|
||||
const { redirect } = JSON.parse(httpErrorResponseData);
|
||||
if (redirect) {
|
||||
global.location.replace(redirect);
|
||||
return true;
|
||||
}
|
||||
} catch (exc) {
|
||||
// ignore any json parse errors, might be an actual 403 without redirect json content
|
||||
}
|
||||
}
|
||||
|
||||
// Did not do any redirecting
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
@@ -201,18 +222,32 @@ export async function getDatesTabData(courseId) {
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return null;
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgressTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
export async function getProgressTabData(courseId, targetUserId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
|
||||
// If targetUserId is passed in, we will get the progress page data
|
||||
// for the user with the provided id, rather than the requesting user.
|
||||
if (targetUserId) {
|
||||
url += `/${targetUserId}/`;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
@@ -241,11 +276,18 @@ export async function getProgressTabData(courseId) {
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return null;
|
||||
}
|
||||
// 401 can be returned for unauthenticated users or users who are not enrolled
|
||||
if (httpErrorStatus === 401) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -295,8 +337,12 @@ export async function getOutlineTabData(courseId) {
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
// TODO: remove this - not needed once the backend uses 403s for redirects
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return {};
|
||||
return null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -316,6 +362,7 @@ export async function getOutlineTabData(courseId) {
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const handoutsHtml = data.handouts_html;
|
||||
const hasScheduledContent = data.has_scheduled_content;
|
||||
const hasEnded = data.has_ended;
|
||||
const offer = camelCaseObject(data.offer);
|
||||
const resumeCourse = camelCaseObject(data.resume_course);
|
||||
@@ -334,6 +381,7 @@ export async function getOutlineTabData(courseId) {
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
handoutsHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
offer,
|
||||
resumeCourse,
|
||||
|
||||
@@ -115,6 +115,20 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
const progressTabData = Factory.build('progressTabData', { courseId });
|
||||
const targetUserId = 2;
|
||||
const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.targetUserId).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ const slice = createSlice({
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
gradesFeatureIsLocked: false,
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
@@ -21,6 +22,7 @@ const slice = createSlice({
|
||||
},
|
||||
fetchTabSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.targetUserId = payload.targetUserId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
@@ -37,6 +39,9 @@ const slice = createSlice({
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
setGradesFeatureStatus: (state, { payload }) => {
|
||||
state.gradesFeatureIsLocked = payload.gradesFeatureIsLocked;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +50,7 @@ export const {
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
setCallToActionToast,
|
||||
setGradesFeatureStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -27,12 +27,12 @@ const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData) {
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
@@ -49,6 +49,11 @@ export function fetchTab(courseId, tab, getTabData) {
|
||||
logError(courseHomeCourseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedTabData && tabDataResult.value === null) {
|
||||
// null tab data indicates that we have redirected elsewhere - just exit and don't visibly stop loading
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchedTabData) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
@@ -62,7 +67,7 @@ export function fetchTab(courseId, tab, getTabData) {
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId }));
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
}
|
||||
@@ -74,8 +79,8 @@ export function fetchDatesTab(courseId) {
|
||||
return fetchTab(courseId, 'dates', getDatesTabData);
|
||||
}
|
||||
|
||||
export function fetchProgressTab(courseId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData);
|
||||
export function fetchProgressTab(courseId, targetUserId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
|
||||
}
|
||||
|
||||
export function fetchOutlineTab(courseId) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function DatesBanner(props) {
|
||||
const {
|
||||
intl,
|
||||
name,
|
||||
bannerClickHandler,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
|
||||
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
|
||||
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-lg-9'}>
|
||||
<strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
|
||||
</strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
|
||||
</div>
|
||||
{bannerClickHandler && (
|
||||
<div className="col-auto col-lg-3 p-lg-0 d-inline-flex align-items-center justify-content-start justify-content-lg-center">
|
||||
<Button variant="outline-primary" className="align-self-center mt-3 mt-lg-0" onClick={bannerClickHandler}>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
bannerClickHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
DatesBanner.defaultProps = {
|
||||
bannerClickHandler: null,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesBanner);
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import DatesBanner from './DatesBanner';
|
||||
import { resetDeadlines } from '../data';
|
||||
|
||||
function DatesBannerContainer({
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
logUpgradeLinkClick,
|
||||
model,
|
||||
tabFetch,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
|
||||
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
|
||||
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
|
||||
const datesBanners = [
|
||||
{
|
||||
name: 'datesTabInfoBanner',
|
||||
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
|
||||
},
|
||||
{
|
||||
name: 'upgradeToCompleteGradedBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToCompleteGraded && verifiedUpgradeLink,
|
||||
clickHandler: () => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'upgradeToResetBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
|
||||
clickHandler: () => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resetDatesBanner',
|
||||
shouldDisplay: resetDates,
|
||||
clickHandler: () => dispatch(resetDeadlines(courseId, model, tabFetch)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
|
||||
<DatesBanner
|
||||
name={banner.name}
|
||||
bannerClickHandler={banner.clickHandler}
|
||||
key={banner.name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBannerContainer.propTypes = {
|
||||
courseDateBlocks: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
datesBannerInfo: PropTypes.shape({
|
||||
contentTypeGatingEnabled: PropTypes.bool.isRequired,
|
||||
missedDeadlines: PropTypes.bool.isRequired,
|
||||
missedGatedContent: PropTypes.bool.isRequired,
|
||||
verifiedUpgradeLink: PropTypes.string,
|
||||
}).isRequired,
|
||||
hasEnded: PropTypes.bool,
|
||||
logUpgradeLinkClick: PropTypes.func,
|
||||
model: PropTypes.string.isRequired,
|
||||
tabFetch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
DatesBannerContainer.defaultProps = {
|
||||
hasEnded: false,
|
||||
logUpgradeLinkClick: () => {},
|
||||
};
|
||||
|
||||
export default DatesBannerContainer;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DatesBannerContainer from './DatesBannerContainer';
|
||||
|
||||
export default DatesBannerContainer;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'datesBanner.datesTabInfoBanner.header': {
|
||||
id: 'datesBanner.datesTabInfoBanner.header',
|
||||
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
|
||||
description: 'Strong text in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.datesTabInfoBanner.body': {
|
||||
id: 'datesBanner.datesTabInfoBanner.body',
|
||||
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
|
||||
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
|
||||
description: 'Body in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.header': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.body': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
|
||||
assignments as part of this course, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.button': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'Button in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.header': {
|
||||
id: 'datesBanner.upgradeToResetBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.body': {
|
||||
id: 'datesBanner.upgradeToResetBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
|
||||
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
|
||||
and shift the past due assignments into the future, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.button': {
|
||||
id: 'datesBanner.upgradeToResetBanner.button',
|
||||
defaultMessage: 'Upgrade to shift due dates',
|
||||
description: 'Button in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.header': {
|
||||
id: 'datesBanner.resetDatesBanner.header',
|
||||
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
|
||||
description: 'Strong text in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.body': {
|
||||
id: 'datesBanner.resetDatesBanner.body',
|
||||
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
|
||||
the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.`,
|
||||
description: 'Body in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.button': {
|
||||
id: 'datesBanner.resetDatesBanner.button',
|
||||
defaultMessage: 'Shift due dates',
|
||||
description: 'Button in Reset Dates Banner',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function Badge({ children, className, ...rest }) {
|
||||
return (
|
||||
<span
|
||||
className={classNames('dates-badge x-small ml-2 position-absolute', className)}
|
||||
data-testid="dates-badge"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Badge.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Badge.defaultProps = {
|
||||
children: null,
|
||||
className: null,
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
.dates-badge {
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
@@ -4,14 +4,17 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import Timeline from './Timeline';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
import Timeline from './timeline/Timeline';
|
||||
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { initDatesMMP2P } from '../../experiments/mm-p2p';
|
||||
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
|
||||
function DatesTab({ intl }) {
|
||||
const {
|
||||
@@ -19,18 +22,19 @@ function DatesTab({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const mmp2p = initDatesMMP2P(courseId);
|
||||
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
@@ -48,16 +52,14 @@ function DatesTab({ intl }) {
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{ !mmp2p.state.isEnabled && (
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
logUpgradeLinkClick={logUpgradeLinkClick}
|
||||
model="dates"
|
||||
tabFetch={fetchDatesTab}
|
||||
/>
|
||||
) }
|
||||
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||
<SuggestedScheduleHeader />
|
||||
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} />
|
||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||
</>
|
||||
)}
|
||||
<Timeline mmp2p={mmp2p} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,19 @@ describe('DatesTab', () => {
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
let courseMetadata = Factory.build('courseHomeMetadata');
|
||||
const { id: courseId } = courseMetadata;
|
||||
|
||||
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
|
||||
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
|
||||
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
|
||||
@@ -61,15 +74,9 @@ describe('DatesTab', () => {
|
||||
|
||||
describe('when receiving a full set of dates data', () => {
|
||||
beforeEach(() => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
const courseMetadata = Factory.build('courseHomeMetadata');
|
||||
const { id: courseId } = courseMetadata;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
@@ -133,34 +140,27 @@ describe('DatesTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dates banner container ', () => {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
|
||||
const { id: courseId } = courseMetadata;
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
describe('Suggested schedule messaging', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
setMetadata({ is_self_paced: true, is_enrolled: true });
|
||||
history.push(`/course/${courseId}/dates`);
|
||||
});
|
||||
|
||||
it('renders datesTabInfoBanner', async () => {
|
||||
it('renders SuggestedScheduleHeader', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: false,
|
||||
missedDeadlines: false,
|
||||
missedGatedContent: false,
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("We've built a suggested schedule to help you stay on track.")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('We’ve built a suggested schedule to help you stay on track. But don’t worry—it’s flexible so you can learn at your own pace.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders upgradeToCompleteGradedBanner', async () => {
|
||||
it('renders UpgradeToCompleteAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: false,
|
||||
@@ -168,15 +168,14 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
|
||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgradeToResetBanner', async () => {
|
||||
it('renders UpgradeToShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
@@ -184,15 +183,15 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
|
||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders resetDatesBanner', async () => {
|
||||
it('renders ShiftDatesAlert', async () => {
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
missedDeadlines: true,
|
||||
@@ -200,7 +199,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
||||
@@ -216,7 +215,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
// confirm "Shift due dates" button has rendered
|
||||
@@ -244,7 +243,7 @@ describe('DatesTab', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in upgradeToCompleteGradedBanner', async () => {
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
@@ -253,7 +252,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
|
||||
@@ -270,7 +269,7 @@ describe('DatesTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in upgradeToResetBanner', async () => {
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
datesTabData.datesBannerInfo = {
|
||||
contentTypeGatingEnabled: true,
|
||||
@@ -279,7 +278,7 @@ describe('DatesTab', () => {
|
||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
render(component);
|
||||
|
||||
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Tooltip, OverlayTrigger } from '@edx/paragon';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from './utils';
|
||||
import { isLearnerAssignment } from '../utils';
|
||||
|
||||
function Day({
|
||||
date,
|
||||
@@ -55,18 +55,16 @@ function Day({
|
||||
|
||||
{/* Content */}
|
||||
<div className="d-inline-block ml-3 pl-2">
|
||||
<div className="mb-1" data-testid="dates-header">
|
||||
<p className="d-inline text-dark-400">
|
||||
<FormattedDate
|
||||
/** [MM-P2P] Experiment */
|
||||
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
|
||||
<FormattedDate
|
||||
/** [MM-P2P] Experiment */
|
||||
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
{badges}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
@@ -82,7 +80,7 @@ function Day({
|
||||
const textColor = available ? 'text-primary-700' : 'text-gray-500';
|
||||
|
||||
return (
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small')} data-testid="dates-item">
|
||||
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
|
||||
<div>
|
||||
<span className="small">
|
||||
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
|
||||
@@ -3,10 +3,10 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import Day from './Day';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
/** [MM-P2P] Experiment (argument) */
|
||||
export default function Timeline({ mmp2p }) {
|
||||
@@ -64,7 +64,7 @@ export default function Timeline({ mmp2p }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled m-0">
|
||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
|
||||
))}
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Badge } from '@edx/paragon';
|
||||
|
||||
import Badge from './Badge';
|
||||
import messages from './messages';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
import messages from '../messages';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
function hasAccess(item) {
|
||||
return item.learnerHasAccess;
|
||||
@@ -38,14 +38,14 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'bg-warning-300',
|
||||
className: 'text-gray-900',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-dark-100',
|
||||
className: 'text-gray-900',
|
||||
bg: 'bg-light-500',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
@@ -72,12 +72,11 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
shownForDay: items.length && items.every(x => !hasAccess(x)),
|
||||
shownForItem: x => !hasAccess(x),
|
||||
icon: faLock,
|
||||
bg: 'bg-dark-500',
|
||||
bg: 'bg-dark-700',
|
||||
className: 'text-white',
|
||||
},
|
||||
];
|
||||
let color = null; // first color of any badge
|
||||
const marginTopStyle = item ? { marginTop: 0 } : { marginTop: '2px' };
|
||||
const badges = (
|
||||
<>
|
||||
{badgesInfo.map(b => {
|
||||
@@ -97,7 +96,7 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
color = b.bg;
|
||||
}
|
||||
return (
|
||||
<Badge key={b.message.id} style={marginTopStyle} className={classNames(b.bg, b.className)}>
|
||||
<Badge key={b.message.id} className={classNames('ml-2', b.bg, b.className)} data-testid="dates-badge">
|
||||
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
|
||||
{intl.formatMessage(b.message)}
|
||||
</Badge>
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -9,20 +9,21 @@ import { AlertList } from '../../generic/user-messages';
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
import CourseGoalCard from './widgets/CourseGoalCard';
|
||||
import CourseHandouts from './widgets/CourseHandouts';
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
import { fetchOutlineTab } from '../data';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
|
||||
import UpgradeCard from './widgets/UpgradeCard';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
import useCourseStartAlert from './alerts/course-start-alert';
|
||||
import usePrivateCourseAlert from './alerts/private-course-alert';
|
||||
import useScheduledContentAlert from './alerts/scheduled-content-alert';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
||||
@@ -37,6 +38,7 @@ function OutlineTab({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
@@ -44,7 +46,6 @@ function OutlineTab({ intl }) {
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
@@ -58,7 +59,6 @@ function OutlineTab({ intl }) {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
hasEnded,
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
@@ -86,17 +86,18 @@ function OutlineTab({ intl }) {
|
||||
};
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, 'outline-course-alerts', 'course_home');
|
||||
const accessExpirationAlertMasquerade = useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, 'outline-course-alerts');
|
||||
const courseStartAlert = useCourseStartAlert(courseId);
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||
const privateCourseAlert = usePrivateCourseAlert(courseId);
|
||||
const scheduledContentAlert = useScheduledContentAlert(courseId);
|
||||
|
||||
const rootCourseId = courses && Object.keys(courses)[0];
|
||||
|
||||
const courseSock = useRef(null);
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
const logUpgradeToShiftDatesLinkClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: 'personalized_learner_schedules',
|
||||
@@ -124,7 +125,7 @@ function OutlineTab({ intl }) {
|
||||
</div>
|
||||
{resumeCourseUrl && (
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
||||
<Button variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -149,24 +150,19 @@ function OutlineTab({ intl }) {
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...accessExpirationAlertMasquerade,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{courseDateBlocks && (
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
logUpgradeLinkClick={logUpgradeLinkClick}
|
||||
model="outline"
|
||||
tabFetch={fetchOutlineTab}
|
||||
/** [MM-P2P] Experiment */
|
||||
isMMP2PEnabled={MMP2P.state.isEnabled}
|
||||
/>
|
||||
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
|
||||
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
|
||||
</>
|
||||
)}
|
||||
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<CourseGoalCard
|
||||
@@ -223,7 +219,7 @@ function OutlineTab({ intl }) {
|
||||
{ MMP2P.state.isEnabled
|
||||
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||
: (
|
||||
<UpgradeCard
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
@@ -232,6 +228,7 @@ function OutlineTab({ intl }) {
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
shouldDisplayBorder
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
@@ -245,16 +242,6 @@ function OutlineTab({ intl }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canShowUpgradeSock && (
|
||||
<CourseSock
|
||||
courseId={courseId}
|
||||
offer={offer}
|
||||
orgKey={org}
|
||||
pageLocation="Home Page"
|
||||
ref={courseSock}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dates Banner', () => {
|
||||
describe('Suggested schedule alerts', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
setMetadata({ is_enrolled: true, is_self_paced: true });
|
||||
setTabData({
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: true,
|
||||
@@ -185,15 +185,15 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders upgradeToReset', async () => {
|
||||
it('renders UpgradeToShiftDatesAlert', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
expect(screen.getByText('You are auditing this course,')).toBeInTheDocument();
|
||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
|
||||
expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument();
|
||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade button in banner', async () => {
|
||||
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
@@ -531,60 +531,22 @@ describe('Outline Tab', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This learner does not have access to this course.', { exact: false });
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows expiration', async () => {
|
||||
it('does not have special masquerade text', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
expiration_date: '2020-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: null,
|
||||
upgrade_url: null,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('Audit Access Expires');
|
||||
});
|
||||
|
||||
it('shows upgrade prompt', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of upgrade link', async () => {
|
||||
setTabData({
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
|
||||
sendTrackEvent.mockClear();
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: 'course_home_audit_access_expires',
|
||||
linkType: 'link',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
|
||||
expect(check).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -697,6 +659,213 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
});
|
||||
it('tracks request cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
it('tracks download cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
it('tracks unverified cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scheduled Content Alert', () => {
|
||||
it('appears correctly', async () => {
|
||||
const now = new Date();
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { hasScheduledContent: true });
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('More content is coming soon!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('Scheduled Content Alert not present without courseBlocks', () => {
|
||||
it('appears correctly', async () => {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
course_blocks: null,
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -726,6 +895,33 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Requesting Certificate Alert', () => {
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Request certificate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate (pdf) Complete Alert', () => {
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -7,85 +7,169 @@ import {
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import certMessages from './messages';
|
||||
import certStatusMessages from '../../../progress-tab/certificate-status/messages';
|
||||
import { requestCert } from '../../../data/thunks';
|
||||
|
||||
export const CERT_STATUS_TYPE = {
|
||||
EARNED_NOT_AVAILABLE: 'earned_but_not_available',
|
||||
DOWNLOADABLE: 'downloadable',
|
||||
REQUESTING: 'requesting',
|
||||
UNVERIFIED: 'unverified',
|
||||
};
|
||||
|
||||
function CertificateStatusAlert({ intl, payload }) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
certStatusType,
|
||||
certStatus,
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
} = payload;
|
||||
|
||||
let variant = '';
|
||||
if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE || certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
variant = 'success';
|
||||
}
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const AlertWrapper = (props) => props.children(props);
|
||||
|
||||
let header = '';
|
||||
let body = '';
|
||||
let buttonVisible = false;
|
||||
let buttonMessage = '';
|
||||
const sendAlertClickTracking = (id) => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent(id, {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
};
|
||||
|
||||
if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
|
||||
|
||||
header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
|
||||
body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ended on {courseEndDateFormatted}. Final grades and certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
certificateAvailableDate: certificateAvailableDateFormatted,
|
||||
}}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
} else if (certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
if (isWebCert) {
|
||||
buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
const renderCertAwardedStatus = () => {
|
||||
const alertProps = {
|
||||
variant: 'success',
|
||||
icon: faCheckCircle,
|
||||
iconClassName: 'alert-icon text-success-500',
|
||||
};
|
||||
if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
|
||||
alertProps.body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
certificateAvailableDate: certificateAvailableDateFormatted,
|
||||
}}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
if (isWebCert) {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
}
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = certURL;
|
||||
alertProps.buttonAction = () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked');
|
||||
};
|
||||
} else if (certStatus === CERT_STATUS_TYPE.REQUESTING) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.requestableButton);
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = '';
|
||||
alertProps.buttonAction = () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked');
|
||||
dispatch(requestCert(courseId));
|
||||
};
|
||||
}
|
||||
buttonVisible = true;
|
||||
return alertProps;
|
||||
};
|
||||
|
||||
const renderNotIDVerifiedStatus = () => {
|
||||
const alertProps = {
|
||||
variant: 'warning',
|
||||
icon: faExclamationTriangle,
|
||||
iconClassName: 'alert-icon text-warning-500',
|
||||
header: intl.formatMessage(certStatusMessages.unverifiedHomeHeader),
|
||||
buttonMessage: intl.formatMessage(certStatusMessages.unverifiedHomeButton),
|
||||
body: intl.formatMessage(certStatusMessages.unverifiedHomeBody),
|
||||
buttonVisible: true,
|
||||
buttonLink: getConfig().SUPPORT_URL_ID_VERIFICATION,
|
||||
buttonAction: () => {
|
||||
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked');
|
||||
},
|
||||
};
|
||||
|
||||
return alertProps;
|
||||
};
|
||||
|
||||
let alertProps = {};
|
||||
switch (certStatus) {
|
||||
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
|
||||
case CERT_STATUS_TYPE.DOWNLOADABLE:
|
||||
case CERT_STATUS_TYPE.REQUESTING:
|
||||
alertProps = renderCertAwardedStatus();
|
||||
break;
|
||||
case CERT_STATUS_TYPE.UNVERIFIED:
|
||||
alertProps = renderNotIDVerifiedStatus();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant={variant}>
|
||||
<div className="row justify-content-between align-items-center">
|
||||
<div className={buttonVisible ? '' : 'col-auto'}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} className="alert-icon text-success-500" />
|
||||
<Alert.Heading>{header}</Alert.Heading>
|
||||
{body}
|
||||
</div>
|
||||
{buttonVisible && (
|
||||
<div className="m-auto m-lg-0 pr-lg-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
href={certURL}
|
||||
>
|
||||
{buttonMessage}
|
||||
</Button>
|
||||
<AlertWrapper {...alertProps}>
|
||||
{({
|
||||
variant,
|
||||
buttonVisible,
|
||||
iconClassName,
|
||||
icon,
|
||||
header,
|
||||
body,
|
||||
buttonAction,
|
||||
buttonLink,
|
||||
buttonMessage,
|
||||
}) => (
|
||||
<Alert variant={variant}>
|
||||
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
|
||||
<div className={buttonVisible ? 'col-lg-8' : 'col-auto'}>
|
||||
<FontAwesomeIcon icon={icon} className={iconClassName} />
|
||||
<Alert.Heading>{header}</Alert.Heading>
|
||||
{body}
|
||||
</div>
|
||||
{buttonVisible && (
|
||||
<div className="flex-grow-0 pt-3 pt-lg-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
href={buttonLink}
|
||||
onClick={() => {
|
||||
if (buttonAction) { buttonAction(); }
|
||||
}}
|
||||
>
|
||||
{buttonMessage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
</Alert>
|
||||
|
||||
)}
|
||||
</AlertWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,11 +177,13 @@ CertificateStatusAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
certificateAvailableDate: PropTypes.string,
|
||||
certStatusType: PropTypes.string,
|
||||
certStatus: PropTypes.string,
|
||||
courseEndDate: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
certURL: PropTypes.string,
|
||||
isWebCert: PropTypes.bool,
|
||||
userTimezone: PropTypes.string,
|
||||
org: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,26 +3,29 @@ import React, { useMemo } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import { CERT_STATUS_TYPE } from './CertificateStatusAlert';
|
||||
|
||||
const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert'));
|
||||
|
||||
function verifyCertStatusType(status) {
|
||||
// This method will only return cert statuses when we want to alert on them.
|
||||
// It should be modified when we want to alert on a new status type.
|
||||
if (status === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
return CERT_STATUS_TYPE.DOWNLOADABLE;
|
||||
switch (status) {
|
||||
case CERT_STATUS_TYPE.DOWNLOADABLE:
|
||||
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
|
||||
case CERT_STATUS_TYPE.REQUESTING:
|
||||
case CERT_STATUS_TYPE.UNVERIFIED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
if (status === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
|
||||
return CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function useCertificateStatusAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
@@ -37,10 +40,7 @@ function useCertificateStatusAlert(courseId) {
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
|
||||
const certStatusType = verifyCertStatusType(certStatus);
|
||||
const isWebCert = downloadUrl === null;
|
||||
|
||||
let certURL = '';
|
||||
@@ -50,17 +50,19 @@ function useCertificateStatusAlert(courseId) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
const hasCertStatus = certStatusType !== '';
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
// Only show if there is a known cert status that we want provide status on.
|
||||
const isVisible = isEnrolled && hasCertStatus;
|
||||
const isVisible = isEnrolled && hasAlertingCertStatus;
|
||||
const payload = {
|
||||
certificateAvailableDate,
|
||||
certURL,
|
||||
certStatusType,
|
||||
certStatus,
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function ScheduledContentAlert({ payload }) {
|
||||
const {
|
||||
datesTabLink,
|
||||
} = payload;
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
|
||||
<div className="col-lg-7">
|
||||
<Alert.Heading>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.scheduled-content.heading"
|
||||
defaultMessage="More content is coming soon!"
|
||||
/>
|
||||
</Alert.Heading>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.scheduled-content.body"
|
||||
defaultMessage="This course will have more content released at a future date. Look out for email updates or check back on this course for updates."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow-0 pt-3 pt-lg-0">
|
||||
{datesTabLink && (
|
||||
<Button
|
||||
href={datesTabLink}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.scheduled-content.button"
|
||||
defaultMessage="View Course Schedule"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ScheduledContentAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
datesTabLink: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ScheduledContentAlert;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const ScheduledContentAlert = React.lazy(() => import('./ScheduledCotentAlert'));
|
||||
|
||||
const useScheduledContentAlert = (courseId) => {
|
||||
const {
|
||||
courseBlocks: {
|
||||
courses,
|
||||
},
|
||||
datesWidget: {
|
||||
datesTabLink,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const hasScheduledContent = (
|
||||
!!courses
|
||||
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
|
||||
);
|
||||
const { isEnrolled } = useModel('courseHomeMeta', courseId);
|
||||
const payload = {
|
||||
datesTabLink,
|
||||
};
|
||||
useAlert(hasScheduledContent && isEnrolled, {
|
||||
code: 'ScheduledContentAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return { ScheduledContentAlert };
|
||||
};
|
||||
|
||||
export default useScheduledContentAlert;
|
||||
@@ -1,59 +0,0 @@
|
||||
.upgrade-card {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.upgrade-card-header{
|
||||
margin: 1.25rem;
|
||||
}
|
||||
|
||||
.upsell-warning{
|
||||
background-color: $danger-100;
|
||||
}
|
||||
|
||||
.upsell-warning-light{
|
||||
background-color: $warning-100;
|
||||
}
|
||||
|
||||
.upsell-warning, .upsell-warning-light{
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upgrade-card-ul{
|
||||
margin-left: 3rem;
|
||||
padding-top: 0.875rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.upgrade-card-li{
|
||||
left: -2.125rem;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.upgrade-card-text{
|
||||
padding-top: 0.875rem;
|
||||
padding-right: 1.25rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.upgrade-card-button{
|
||||
margin-left: 1.25rem;
|
||||
margin-right: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.discount-info {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
|
||||
.inline-link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.upgrade-card .upgrade-card-message a{
|
||||
color: $primary-500;
|
||||
}
|
||||
@@ -12,16 +12,23 @@ import messages from './messages';
|
||||
function ProgressHeader({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
targetUserId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const { administrator, userId } = getAuthenticatedUser();
|
||||
|
||||
const { studioUrl } = useModel('progress', courseId);
|
||||
const { studioUrl, username } = useModel('progress', courseId);
|
||||
|
||||
const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId);
|
||||
|
||||
const pageTitle = viewingOtherStudentsProgressPage
|
||||
? intl.formatMessage(messages.progressHeaderForTargetUser, { username })
|
||||
: intl.formatMessage(messages.progressHeader);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
||||
<h1>{intl.formatMessage(messages.progressHeader)}</h1>
|
||||
<h1>{pageTitle}</h1>
|
||||
{administrator && studioUrl && (
|
||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import CertificateStatus from './certificate-status/CertificateStatus';
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -10,6 +10,7 @@ import GradeSummary from './grades/grade-summary/GradeSummary';
|
||||
import ProgressHeader from './ProgressHeader';
|
||||
import RelatedLinks from './related-links/RelatedLinks';
|
||||
|
||||
import { setGradesFeatureStatus } from '../data/slice';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function ProgressTab() {
|
||||
@@ -22,8 +23,14 @@ function ProgressTab() {
|
||||
lockedCount,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
const isLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
|
||||
|
||||
const gradesFeatureIsLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = gradesFeatureIsLocked ? 'locked-overlay' : '';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(setGradesFeatureStatus({ gradesFeatureIsLocked }));
|
||||
}, []);
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
@@ -43,7 +50,7 @@ function ProgressTab() {
|
||||
<CertificateStatus />
|
||||
</OnMobile>
|
||||
<CourseGrade />
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={isLocked}>
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsLocked}>
|
||||
<GradeSummary />
|
||||
<DetailedGrades />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('Progress Tab', () => {
|
||||
const courseId = 'course-v1:edX+Test+run';
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/*`);
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
@@ -1034,4 +1034,16 @@ describe('Progress Tab', () => {
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewing progress page of other students by changing url', () => {
|
||||
it('Changing the url changes the header', async () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({ username: 'otherstudent' });
|
||||
|
||||
await executeThunk(thunks.fetchProgressTab(courseId, 10), store.dispatch);
|
||||
await act(async () => render(<ProgressTab />, { store }));
|
||||
|
||||
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,9 @@ function CertificateStatus({ intl }) {
|
||||
verificationData,
|
||||
verifiedMode,
|
||||
} = useModel('progress', courseId);
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
@@ -63,6 +66,7 @@ function CertificateStatus({ intl }) {
|
||||
let buttonLocation;
|
||||
let buttonText;
|
||||
let endDate;
|
||||
let certAvailabilityDate;
|
||||
|
||||
let gradeEventName = 'not_passing';
|
||||
if (userHasPassingGrade) {
|
||||
@@ -137,12 +141,13 @@ function CertificateStatus({ intl }) {
|
||||
case 'earned_but_not_available':
|
||||
certCase = 'notAvailable';
|
||||
endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||
certAvailabilityDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="Your certificate will be available soon! After this course officially ends on {endDate}, you will receive an
|
||||
email notification with your certificate."
|
||||
values={{ endDate }}
|
||||
defaultMessage="This course ends on {endDate}. Final grades and certificates are
|
||||
scheduled to be available after {certAvailabilityDate}."
|
||||
values={{ endDate, certAvailabilityDate }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -61,10 +61,6 @@ const messages = defineMessages({
|
||||
id: 'progress.certificateStatus.notAvailableHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
notAvailableBody: {
|
||||
id: 'progress.certificateStatus.notAvailableBody',
|
||||
defaultMessage: 'Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.',
|
||||
},
|
||||
upgradeHeader: {
|
||||
id: 'progress.certificateStatus.upgradeHeader',
|
||||
defaultMessage: 'Earn a certificate',
|
||||
@@ -77,6 +73,18 @@ const messages = defineMessages({
|
||||
id: 'progress.certificateStatus.upgradeButton',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
unverifiedHomeHeader: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeHeader',
|
||||
defaultMessage: 'Verify your identity to earn a certificate!',
|
||||
},
|
||||
unverifiedHomeButton: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeButton',
|
||||
defaultMessage: 'Verify my ID',
|
||||
},
|
||||
unverifiedHomeBody: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeBody',
|
||||
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7,6 +7,10 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
if (!completePercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [showCompletePopover, setShowCompletePopover] = useState(false);
|
||||
|
||||
const completeSegmentOffset = (3.6 * completePercentage) / 8;
|
||||
@@ -24,15 +28,6 @@ function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
onFocus={() => setShowCompletePopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-segment complete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
|
||||
strokeDashoffset={lockedSegmentOffset + completePercentage}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showCompletePopover}
|
||||
@@ -49,16 +44,33 @@ function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
<rect x="19" y="3" style={{ transform: `rotate(${completeTooltipDegree}deg)` }} />
|
||||
</OverlayTrigger>
|
||||
|
||||
{/* Complete segment */}
|
||||
<circle
|
||||
className="donut-segment complete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
|
||||
strokeDashoffset={lockedSegmentOffset + completePercentage}
|
||||
/>
|
||||
|
||||
{/* Segment dividers */}
|
||||
{lockedPercentage > 0 && lockedPercentage < 100 && (
|
||||
<circle
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
className="donut-segment divider-stroke"
|
||||
strokeDasharray="0.3 99.7"
|
||||
strokeDashoffset={0.15 + lockedSegmentOffset}
|
||||
/>
|
||||
)}
|
||||
{completePercentage < 100 && lockedPercentage < 100 && lockedPercentage + completePercentage === 100 && (
|
||||
{completePercentage < 100 && lockedPercentage > 0 && lockedPercentage < 100
|
||||
&& lockedPercentage + completePercentage === 100 && (
|
||||
<circle
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
className="donut-segment divider-stroke"
|
||||
strokeDasharray="0.3 99.7"
|
||||
strokeDashoffset="25.15"
|
||||
|
||||
@@ -7,6 +7,10 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
function IncompleteDonutSegment({ incompletePercentage, intl }) {
|
||||
if (!incompletePercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
|
||||
|
||||
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
|
||||
|
||||
@@ -9,7 +9,7 @@ import messages from './messages';
|
||||
function LockedDonutSegment({ intl, lockedPercentage }) {
|
||||
const [showLockedPopover, setShowLockedPopover] = useState(false);
|
||||
|
||||
if (!lockedPercentage > 0) {
|
||||
if (!lockedPercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,10 @@ import messages from '../messages';
|
||||
function CourseGrade({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
@@ -26,13 +24,12 @@ function CourseGrade({ intl }) {
|
||||
|
||||
const passingGrade = Number((Math.min(...Object.values(gradeRange)) * 100).toFixed(0));
|
||||
|
||||
const isLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
|
||||
const applyLockedOverlay = gradesFeatureIsLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<section className="text-dark-700 my-4 rounded shadow-sm">
|
||||
{isLocked && <CourseGradeHeader />}
|
||||
<div className={applyLockedOverlay}>
|
||||
{gradesFeatureIsLocked && <CourseGradeHeader />}
|
||||
<div className={applyLockedOverlay} aria-hidden={gradesFeatureIsLocked}>
|
||||
<div className="row w-100 m-0 p-4">
|
||||
<div className="col-12 col-sm-6 p-0 pr-sm-2">
|
||||
<h2>{intl.formatMessage(messages.grades)}</h2>
|
||||
|
||||
@@ -12,12 +12,10 @@ import messages from '../messages';
|
||||
function GradeBar({ intl, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
@@ -26,8 +24,7 @@ function GradeBar({ intl, passingGrade }) {
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
|
||||
const isLocked = lockedCount > 0;
|
||||
const lockedTooltipClassName = isLocked ? 'locked-overlay' : '';
|
||||
const lockedTooltipClassName = gradesFeatureIsLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<div className="col-12 col-sm-6 align-self-center">
|
||||
|
||||
@@ -14,6 +14,7 @@ import messages from '../messages';
|
||||
function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
@@ -67,6 +68,7 @@ function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
disabled={gradesFeatureIsLocked}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ function DetailedGrades({ intl }) {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
@@ -39,6 +40,7 @@ function DetailedGrades({ intl }) {
|
||||
className="muted-link inline-link"
|
||||
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
|
||||
onClick={logOutlineLinkClick}
|
||||
tabIndex={gradesFeatureIsLocked ? '-1' : '0'}
|
||||
>
|
||||
{intl.formatMessage(messages.courseOutline)}
|
||||
</Hyperlink>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useModel } from '../../../../generic/model-store';
|
||||
function DetailedGradesTable({ intl, sectionScores }) {
|
||||
const {
|
||||
courseId,
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
@@ -43,10 +44,11 @@ function DetailedGradesTable({ intl, sectionScores }) {
|
||||
const title = (
|
||||
<a
|
||||
href={subsection.url}
|
||||
className="text-dark-700 small"
|
||||
className="muted-link small"
|
||||
onClick={() => {
|
||||
logSubsectionClicked(subsection.blockKey);
|
||||
}}
|
||||
tabIndex={gradesFeatureIsLocked ? '-1' : '0'}
|
||||
>
|
||||
{subsection.displayName}
|
||||
</a>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function AssignmentTypeCell({ assignmentType, footnoteMarker, footnoteId }) {
|
||||
const {
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
return (
|
||||
<div className="small">
|
||||
{assignmentType}
|
||||
{footnoteId && footnoteMarker && (
|
||||
<sup>
|
||||
<a id={`${footnoteId}-ref`} className="text-dark-700" href={`#${footnoteId}-footnote`} aria-describedby="grade-summary-footnote-label">
|
||||
<a
|
||||
id={`${footnoteId}-ref`}
|
||||
className="muted-link"
|
||||
href={`#${footnoteId}-footnote`}
|
||||
aria-describedby="grade-summary-footnote-label"
|
||||
tabIndex={gradesFeatureIsLocked ? '-1' : '0'}
|
||||
>
|
||||
{footnoteMarker}
|
||||
</a>
|
||||
</sup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -6,6 +7,9 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
|
||||
import messages from '../messages';
|
||||
|
||||
function DroppableAssignmentFootnote({ footnotes, intl }) {
|
||||
const {
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
return (
|
||||
<>
|
||||
<span id="grade-summary-footnote-label" className="sr-only">{intl.formatMessage(messages.footnotesTitle)}</span>
|
||||
@@ -21,7 +25,9 @@ function DroppableAssignmentFootnote({ footnotes, intl }) {
|
||||
assignmentType: footnote.assignmentType,
|
||||
}}
|
||||
/>
|
||||
<a className="sr-only" href={`#${footnote.id}-ref`}>{intl.formatMessage(messages.backToContent)}</a>
|
||||
<a className="sr-only" href={`#${footnote.id}-ref`} tabIndex={gradesFeatureIsLocked ? '-1' : '0'}>
|
||||
{intl.formatMessage(messages.backToContent)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -9,6 +10,9 @@ import { InfoOutline } from '@edx/paragon/icons';
|
||||
import messages from '../messages';
|
||||
|
||||
function GradeSummaryHeader({ intl }) {
|
||||
const {
|
||||
gradesFeatureIsLocked,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
return (
|
||||
<div className="row w-100 m-0 align-items-center">
|
||||
@@ -33,6 +37,7 @@ function GradeSummaryHeader({ intl }) {
|
||||
iconAs={Icon}
|
||||
className="mb-3"
|
||||
size="sm"
|
||||
disabled={gradesFeatureIsLocked}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ function GradeSummaryTable({ intl }) {
|
||||
|
||||
return {
|
||||
type: { footnoteId, footnoteMarker, type: assignment.type },
|
||||
weight: `${assignment.weight * 100}%`,
|
||||
weight: `${(assignment.weight * 100).toFixed(0)}%`,
|
||||
grade: `${(assignment.averageGrade * 100).toFixed(0)}%`,
|
||||
weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}%`,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,11 @@ const messages = defineMessages({
|
||||
id: 'progress.header',
|
||||
defaultMessage: 'Your progress',
|
||||
},
|
||||
progressHeaderForTargetUser: {
|
||||
id: 'progress.header.targetUser',
|
||||
defaultMessage: 'Course progress for {username}',
|
||||
description: 'Header when displaying the progress for a different user',
|
||||
},
|
||||
studioLink: {
|
||||
id: 'progress.link.studio',
|
||||
defaultMessage: 'View grading in Studio',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { resetDeadlines } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function ShiftDatesAlert({ fetch, intl, model }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
} = datesBannerInfo;
|
||||
|
||||
if (!missedDeadlines || missedGatedContent || hasEnded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<Row className="w-100 m-0">
|
||||
<Col xs={12} md={9} className="small p-0 pr-md-2">
|
||||
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong>
|
||||
{' '}{intl.formatMessage(messages.shiftDatesBody)}
|
||||
</Col>
|
||||
<Col xs={12} md={3} className="align-self-center text-right mt-3 mt-md-0 p-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => dispatch(resetDeadlines(courseId, model, fetch))}
|
||||
>
|
||||
{intl.formatMessage(messages.shiftDatesButton)}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ShiftDatesAlert.propTypes = {
|
||||
fetch: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
model: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ShiftDatesAlert);
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function SuggestedScheduleHeader({ intl }) {
|
||||
return (
|
||||
<p className="large">
|
||||
{intl.formatMessage(messages.suggestedSchedule)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
SuggestedScheduleHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SuggestedScheduleHeader);
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Row,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function UpgradeToCompleteAlert({ intl, logUpgradeLinkClick }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
if (!contentTypeGatingEnabled || missedDeadlines || hasEnded || !verifiedUpgradeLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-light-200">
|
||||
<Row className="w-100 m-0">
|
||||
<Col xs={12} md={9} className="small p-0 pr-md-2">
|
||||
<Alert.Heading>{intl.formatMessage(messages.upgradeToCompleteHeader)}</Alert.Heading>
|
||||
{intl.formatMessage(messages.upgradeToCompleteBody)}
|
||||
</Col>
|
||||
<Col xs={12} md={3} className="align-self-center text-right mt-3 mt-md-0 p-0">
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeToCompleteButton)}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeToCompleteAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
logUpgradeLinkClick: PropTypes.func,
|
||||
};
|
||||
|
||||
UpgradeToCompleteAlert.defaultProps = {
|
||||
logUpgradeLinkClick: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeToCompleteAlert);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function UpgradeToShiftDatesAlert({ intl, logUpgradeLinkClick, model }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
if (!(contentTypeGatingEnabled && missedDeadlines && missedGatedContent && verifiedUpgradeLink) || hasEnded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-light-200">
|
||||
<Row className="w-100 m-0">
|
||||
<Col xs={12} md={9} className="small p-0 pr-md-2">
|
||||
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong>
|
||||
{' '}{intl.formatMessage(messages.upgradeToShiftBody)}
|
||||
</Col>
|
||||
<Col xs={12} md={3} className="align-self-center text-right mt-3 mt-md-0 p-0">
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="w-xs-100 w-md-auto"
|
||||
onClick={() => {
|
||||
logUpgradeLinkClick();
|
||||
global.location.replace(verifiedUpgradeLink);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeToShiftButton)}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeToShiftDatesAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
logUpgradeLinkClick: PropTypes.func,
|
||||
model: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UpgradeToShiftDatesAlert.defaultProps = {
|
||||
logUpgradeLinkClick: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeToShiftDatesAlert);
|
||||
11
src/course-home/suggested-schedule-messaging/index.js
Normal file
11
src/course-home/suggested-schedule-messaging/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import ShiftDatesAlert from './ShiftDatesAlert';
|
||||
import SuggestedScheduleHeader from './SuggestedScheduleHeader';
|
||||
import UpgradeToCompleteAlert from './UpgradeToCompleteAlert';
|
||||
import UpgradeToShiftDatesAlert from './UpgradeToShiftDatesAlert';
|
||||
|
||||
export {
|
||||
ShiftDatesAlert,
|
||||
SuggestedScheduleHeader,
|
||||
UpgradeToCompleteAlert,
|
||||
UpgradeToShiftDatesAlert,
|
||||
};
|
||||
51
src/course-home/suggested-schedule-messaging/messages.js
Normal file
51
src/course-home/suggested-schedule-messaging/messages.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
suggestedSchedule: {
|
||||
id: 'datesBanner.suggestedSchedule',
|
||||
defaultMessage: 'We’ve built a suggested schedule to help you stay on track. But don’t worry—it’s flexible so you'
|
||||
+ ' can learn at your own pace.',
|
||||
},
|
||||
upgradeToCompleteHeader: {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
|
||||
defaultMessage: 'Upgrade to unlock',
|
||||
description: 'Messaging that prompts users to upgrade their course status in order to access locked course content',
|
||||
},
|
||||
upgradeToCompleteBody: {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
|
||||
defaultMessage: 'You are auditing this course, which means that you are unable to participate in graded'
|
||||
+ ' assignments. To complete graded assignments as part of this course, you can upgrade today.',
|
||||
},
|
||||
upgradeToCompleteButton: {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'Button that prompts users to upgrade their course status',
|
||||
},
|
||||
upgradeToShiftBody: {
|
||||
id: 'datesBanner.upgradeToResetBanner.body',
|
||||
defaultMessage: 'To keep yourself on track, you can update this schedule and shift the past due assignments into'
|
||||
+ ' the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.',
|
||||
},
|
||||
upgradeToShiftButton: {
|
||||
id: 'datesBanner.upgradeToResetBanner.button',
|
||||
defaultMessage: 'Upgrade to shift due dates',
|
||||
description: 'Button that prompts users to upgrade their course status before they can shift their due dates into'
|
||||
+ ' the future',
|
||||
},
|
||||
missedDeadlines: {
|
||||
id: 'datesBanner.resetDatesBanner.header',
|
||||
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule.',
|
||||
},
|
||||
shiftDatesBody: {
|
||||
id: 'datesBanner.resetDatesBanner.body',
|
||||
defaultMessage: 'To keep yourself on track, you can update this schedule and shift the past due assignments into'
|
||||
+ ' the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.',
|
||||
},
|
||||
shiftDatesButton: {
|
||||
id: 'datesBanner.resetDatesBanner.button',
|
||||
defaultMessage: 'Shift due dates',
|
||||
description: 'Button that prompts users to move their due dates into the future',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -59,9 +59,11 @@ const checkUnitToSequenceUnitRedirect = memoize((courseStatus, courseId, sequenc
|
||||
}
|
||||
});
|
||||
|
||||
const checkSpecialExamRedirect = memoize((sequenceStatus, sequence) => {
|
||||
const checkSpecialExamRedirect = memoize((sequenceStatus, sequence, specialExamsEnabled, proctoredExamsEnabled) => {
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequence.isTimeLimited && sequence.legacyWebUrl !== undefined) {
|
||||
const shouldRedirectTimeLimited = sequence.isTimeLimited && !specialExamsEnabled;
|
||||
const shouldRedirectProctored = sequence.isProctored && !proctoredExamsEnabled;
|
||||
if ((shouldRedirectTimeLimited || shouldRedirectProctored) && sequence.legacyWebUrl !== undefined) {
|
||||
global.location.assign(sequence.legacyWebUrl);
|
||||
}
|
||||
}
|
||||
@@ -178,11 +180,7 @@ class CoursewareContainer extends Component {
|
||||
// Check special exam redirect:
|
||||
// /course/:courseId/:sequenceId(/:unitId) -> :legacyWebUrl
|
||||
// because special exams are currently still served in the legacy LMS frontend.
|
||||
const shouldRedirectProctoredExams = specialExamsEnabledWaffleFlag && sequence.isProctored
|
||||
&& !proctoredExamsEnabledWaffleFlag;
|
||||
if (!specialExamsEnabledWaffleFlag || shouldRedirectProctoredExams) {
|
||||
checkSpecialExamRedirect(sequenceStatus, sequence);
|
||||
}
|
||||
checkSpecialExamRedirect(sequenceStatus, sequence, specialExamsEnabledWaffleFlag, proctoredExamsEnabledWaffleFlag);
|
||||
|
||||
// Check to sequence to sequence-unit redirect:
|
||||
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
|
||||
|
||||
@@ -121,6 +121,9 @@ describe('CoursewareContainer', () => {
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
const courseMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
|
||||
|
||||
@@ -2,19 +2,16 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import useOfferAlert from '../../alerts/offer-alert';
|
||||
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import SidebarNotificationButton from './SidebarNotificationButton';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
@@ -42,36 +39,29 @@ function Course({
|
||||
].filter(element => element != null).map(element => element.title);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
celebrations,
|
||||
offer,
|
||||
org,
|
||||
userTimezone,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const offerAlert = useOfferAlert(courseId, offer, org, userTimezone, 'course', 'in_course');
|
||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, 'course', 'in_course');
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const celebrationOpen = shouldCelebrateOnSectionLoad(
|
||||
courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations,
|
||||
);
|
||||
|
||||
// REV-2130 TODO: temporary cookie code that should be removed.
|
||||
// In order to see the Value Prop sidebar in prod, a cookie should be set in
|
||||
// the browser console and refresh: document.cookie = 'value_prop_cookie=true';
|
||||
const isValuePropCookieSet = Cookies.get('value_prop_cookie') === 'true';
|
||||
const shouldDisplayNotificationTrigger = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const shouldDisplaySidebarButton = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
|
||||
const shouldDisplayNotificationTrayOpen = useWindowSize().width > responsiveBreakpoints.medium.minWidth;
|
||||
|
||||
const [sidebarVisible, setSidebar] = useState(false);
|
||||
const isSidebarVisible = () => sidebarVisible && setSidebar;
|
||||
const toggleSidebar = () => {
|
||||
if (sidebarVisible) { setSidebar(false); } else { setSidebar(true); }
|
||||
const [notificationTrayVisible, setNotificationTray] = verifiedMode
|
||||
&& shouldDisplayNotificationTrayOpen ? useState(true) : useState(false);
|
||||
const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray;
|
||||
const toggleNotificationTray = () => {
|
||||
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
|
||||
};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -82,17 +72,6 @@ function Course({
|
||||
<Helmet>
|
||||
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
{ /** This conditional is for the [MM-P2P] Experiment */}
|
||||
{ !MMP2P.state.isEnabled && (
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...offerAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="position-relative">
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
@@ -102,10 +81,10 @@ function Course({
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
{ isValuePropCookieSet && shouldDisplaySidebarButton ? (
|
||||
<SidebarNotificationButton
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
{ shouldDisplayNotificationTrigger ? (
|
||||
<NotificationTrigger
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -118,10 +97,9 @@ function Course({
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
sidebarVisible={sidebarVisible}
|
||||
isValuePropCookieSet={isValuePropCookieSet}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import Cookies from 'js-cookie';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
|
||||
} from '../../setupTest';
|
||||
import Course from './Course';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import useWindowSize from '../../generic/tabs/useWindowSize';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('../../generic/tabs/useWindowSize');
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
@@ -20,7 +21,6 @@ describe('Course', () => {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
unitNavigationHandler: () => {},
|
||||
toggleSidebar: () => {},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -87,115 +87,16 @@ describe('Course', () => {
|
||||
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays sidebar notification button', async () => {
|
||||
const toggleSidebar = jest.fn();
|
||||
const isSidebarVisible = jest.fn();
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
render(<Course {...mockData} />);
|
||||
|
||||
// REV-2130 TODO: remove cookie related code once temporary value prop cookie code is removed.
|
||||
const cookieName = 'value_prop_cookie';
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn().mockImplementation(() => cookieName);
|
||||
const getSpy = jest.spyOn(Cookies, 'get').mockReturnValueOnce('true');
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleSidebar,
|
||||
isSidebarVisible,
|
||||
};
|
||||
render(<Course {...testData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
|
||||
const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i });
|
||||
|
||||
expect(getSpy).toBeCalledWith(cookieName);
|
||||
expect(sidebarOpenButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays offer and expiration alert', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: null,
|
||||
upgrade_url: null,
|
||||
},
|
||||
offer: {
|
||||
code: 'EDXWELCOME',
|
||||
expiration_date: '2070-01-01T12:00:00Z',
|
||||
original_price: '$100',
|
||||
discounted_price: '$85',
|
||||
percentage: 15,
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
|
||||
await screen.findByText('EDXWELCOME');
|
||||
await screen.findByText('Audit Access Expires');
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of access expiration upgrade link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '2080-01-01T12:00:00Z',
|
||||
masquerading_expired_course: false,
|
||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
user_timezone: 'UTC',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
await screen.findByText('Audit Access Expires');
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseMetadata.id,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: 'in_course_audit_access_expires',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of offer alert link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
offer: {
|
||||
code: 'EDXWELCOME',
|
||||
expiration_date: '2070-01-01T12:00:00Z',
|
||||
original_price: '$100',
|
||||
discounted_price: '$85',
|
||||
percentage: 15,
|
||||
upgrade_url: 'https://example.com/upgrade',
|
||||
},
|
||||
user_timezone: 'UTC',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
await screen.findByText('EDXWELCOME');
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
|
||||
fireEvent.click(upgradeLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseMetadata.id,
|
||||
linkCategory: 'welcome',
|
||||
linkName: 'in_course_welcome',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger).toHaveClass('trigger-active');
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger).not.toHaveClass('trigger-active');
|
||||
});
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import messages from './messages';
|
||||
function NotificationIcon({ intl, status, notificationColor }) {
|
||||
return (
|
||||
<div className="icon-container">
|
||||
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openSidebarButton)} />
|
||||
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openNotificationTrigger)} />
|
||||
{status === 'active'
|
||||
? <span className={classNames(notificationColor, 'notification-dot')} data-testid="notification-dot" />
|
||||
: null}
|
||||
|
||||
82
src/courseware/course/NotificationTray.jsx
Normal file
82
src/courseware/course/NotificationTray.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { ArrowBackIos, Close } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
|
||||
function NotificationTray({
|
||||
intl, toggleNotificationTray,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
contentTypeGatingEnabled,
|
||||
offer,
|
||||
org,
|
||||
timeOffsetMillis,
|
||||
userTimezone,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
const shouldDisplayFullScreen = useWindowSize().width < responsiveBreakpoints.large.minWidth;
|
||||
|
||||
return (
|
||||
<section className={classNames('notification-tray-container ml-0 ml-lg-4', { 'no-notification': !verifiedMode && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.notificationTray)}>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div className="mobile-close-container" onClick={() => { toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="mobile-close">{intl.formatMessage(messages.responsiveCloseNotificationTray)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="notification-tray-title">{intl.formatMessage(messages.notificationTitle)}</span>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: (
|
||||
<div className="d-inline-flex close-btn">
|
||||
<IconButton src={Close} size="sm" iconAs={Icon} onClick={() => { toggleNotificationTray(); }} variant="primary" alt={intl.formatMessage(messages.closeNotificationTrigger)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="notification-tray-divider" />
|
||||
<div>{verifiedMode
|
||||
? (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
userTimezone={userTimezone}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
shouldDisplayBorder={false}
|
||||
/>
|
||||
) : <p className="notification-tray-content">{intl.formatMessage(messages.noNotificationsMessage)}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTray.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func,
|
||||
};
|
||||
|
||||
NotificationTray.defaultProps = {
|
||||
toggleNotificationTray: null,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTray);
|
||||
@@ -1,8 +1,9 @@
|
||||
.sidebar-container {
|
||||
.notification-tray-container {
|
||||
border: 1px solid $light-400;
|
||||
border-radius: 4px;
|
||||
width: 20rem;
|
||||
width: 31rem;
|
||||
vertical-align: top;
|
||||
height: 100%;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'lg')) {
|
||||
position: fixed;
|
||||
@@ -11,7 +12,6 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100% !important;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
border: none;
|
||||
@@ -23,27 +23,25 @@
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0.625rem 0;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
.notification-tray-title {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 0 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
float: right;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
width: 100.5%;
|
||||
.notification-tray-divider {
|
||||
height: 0.5rem;
|
||||
background: $gray-100;
|
||||
border: 1px solid $light-400;
|
||||
border-left: 0;
|
||||
border-top: 1px solid $light-400;
|
||||
border-bottom: 1px solid $light-400;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
.notification-tray-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
100
src/courseware/course/NotificationTray.test.jsx
Normal file
100
src/courseware/course/NotificationTray.test.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { fetchCourse } from '../data';
|
||||
import {
|
||||
render, initializeMockApp, screen, fireEvent, waitFor,
|
||||
} from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
import NotificationTray from './NotificationTray';
|
||||
import useWindowSize from '../../generic/tabs/useWindowSize';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('../../generic/tabs/useWindowSize');
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('NotificationTray', () => {
|
||||
let mockData;
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
const defaultMetadata = Factory.build('courseMetadata', { id: courseId });
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
mockData = {
|
||||
toggleNotificationTray: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders notification tray and close tray button', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
await fetchAndRender(<NotificationTray {...testData} />);
|
||||
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
const notificationCloseIconButton = screen.getByRole('button', { name: /Close notification tray/i });
|
||||
expect(notificationCloseIconButton).toBeInTheDocument();
|
||||
expect(notificationCloseIconButton).toHaveClass('btn-icon-primary');
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
|
||||
// should not render responsive "Back to course" to close the tray
|
||||
expect(screen.queryByText('Back to course')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(<NotificationTray />);
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
|
||||
expect(UpgradeNotification).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no notifications message if no verified mode', async () => {
|
||||
setMetadata({ verified_mode: null });
|
||||
await fetchAndRender(<NotificationTray />);
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification tray with full screen "Back to course" at responsive view', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 991 });
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
await fetchAndRender(<NotificationTray {...testData} />);
|
||||
|
||||
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
|
||||
await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(responsiveCloseButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
29
src/courseware/course/NotificationTrigger.jsx
Normal file
29
src/courseware/course/NotificationTrigger.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import NotificationIcon from './NotificationIcon';
|
||||
import messages from './messages';
|
||||
|
||||
function NotificationTrigger({ intl, toggleNotificationTray, isNotificationTrayVisible }) {
|
||||
return (
|
||||
<button
|
||||
className={classNames('notification-trigger-btn', { 'trigger-active': isNotificationTrayVisible() })}
|
||||
type="button"
|
||||
onClick={() => { toggleNotificationTray(); }}
|
||||
aria-label={intl.formatMessage(messages.openNotificationTrigger)}
|
||||
>
|
||||
{/* REV-2297 TODO: add logic for status "active" if red dot should display */}
|
||||
<NotificationIcon status="inactive" notificationColor="bg-danger-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTrigger.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func.isRequired,
|
||||
isNotificationTrayVisible: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTrigger);
|
||||
@@ -1,4 +1,4 @@
|
||||
.sidebar-notification-btn {
|
||||
.notification-trigger-btn {
|
||||
border: 1px solid $light-400;
|
||||
background: none;
|
||||
margin-top: 0.625rem;
|
||||
@@ -14,6 +14,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border-bottom: 3px solid $primary-700;
|
||||
.trigger-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-bottom: 2px solid $primary-700;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
}
|
||||
43
src/courseware/course/NotificationTrigger.test.jsx
Normal file
43
src/courseware/course/NotificationTrigger.test.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, initializeTestStore, screen, fireEvent,
|
||||
} from '../../setupTest';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
|
||||
describe('Notification Trigger', () => {
|
||||
let mockData;
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
mockData = {
|
||||
toggleNotificationTray: () => {},
|
||||
isNotificationTrayVisible: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders notification trigger with icon', async () => {
|
||||
const { container } = render(<NotificationTrigger {...mockData} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
const buttonIcon = container.querySelectorAll('svg');
|
||||
expect(buttonIcon).toHaveLength(1);
|
||||
|
||||
// REV-2297 TODO: update below test once the status=active or inactive is implemented
|
||||
// expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles onClick event toggling the notification tray', async () => {
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
render(<NotificationTrigger {...testData} />);
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { ArrowBackIos, Close } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
|
||||
|
||||
function Sidebar({
|
||||
intl, toggleSidebar,
|
||||
}) {
|
||||
const shouldDisplayFullScreen = useWindowSize().width < responsiveBreakpoints.large.minWidth;
|
||||
|
||||
// REV-2130 TODO: temporary variable set to true, should be replaced with
|
||||
// whether the course can be upgraded (ie. shouldDisplayUpgradeNotification)
|
||||
const shouldDisplayNoNotification = true;
|
||||
|
||||
return (
|
||||
<section className={classNames('sidebar-container ml-0 ml-lg-4', { 'no-notification': shouldDisplayNoNotification && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.sidebarNotification)}>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div className="mobile-close-container" onClick={() => { toggleSidebar(); }} onKeyDown={() => { toggleSidebar(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseSidebar)}>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="mobile-close">{intl.formatMessage(messages.responsiveCloseSidebar)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sidebar-header px-3">
|
||||
<span>{intl.formatMessage(messages.notificationTitle)}</span>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: <Icon src={Close} className="close-btn" onClick={() => { toggleSidebar(); }} onKeyDown={() => { toggleSidebar(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.closeSidebarButton)} />}
|
||||
</div>
|
||||
<div className="sidebar-divider" />
|
||||
<div className="sidebar-content">
|
||||
{/* REV-2130 TODO: replace logic to display upgrade expiration box if condition is true */}
|
||||
{shouldDisplayNoNotification ? <p>{intl.formatMessage(messages.noNotificationsMessage)}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Sidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
toggleSidebar: PropTypes.func,
|
||||
};
|
||||
|
||||
Sidebar.defaultProps = {
|
||||
toggleSidebar: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Sidebar);
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, initializeTestStore, screen, fireEvent, waitFor,
|
||||
} from '../../setupTest';
|
||||
import Sidebar from './Sidebar';
|
||||
import useWindowSize from '../../generic/tabs/useWindowSize';
|
||||
|
||||
jest.mock('../../generic/tabs/useWindowSize');
|
||||
|
||||
describe('Sidebar', () => {
|
||||
let mockData;
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
|
||||
beforeEach(async () => {
|
||||
mockData = {
|
||||
toggleSidebar: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
});
|
||||
|
||||
it('renders sidebar', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 1200, height: 422 });
|
||||
const { container } = render(<Sidebar {...mockData} />);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container).toHaveTextContent('Notifications');
|
||||
expect(container).not.toHaveTextContent('Back to course');
|
||||
});
|
||||
|
||||
it('renders no notifications message', async () => {
|
||||
// REV-2130 TODO: add conditional if no expiration box/upgradeable
|
||||
const testData = { ...mockData };
|
||||
const { container } = render(<Sidebar {...testData} />);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container).toHaveTextContent('You have no new notifications at this time.');
|
||||
});
|
||||
|
||||
it('renders sidebar with full screen "Back to course" at response width', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 991, height: 422 });
|
||||
const toggleSidebar = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleSidebar,
|
||||
};
|
||||
render(<Sidebar {...testData} />);
|
||||
|
||||
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
|
||||
await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(responsiveCloseButton);
|
||||
expect(toggleSidebar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import NotificationIcon from './NotificationIcon';
|
||||
import messages from './messages';
|
||||
|
||||
function SidebarNotificationButton({ intl, toggleSidebar, isSidebarVisible }) {
|
||||
return (
|
||||
<button
|
||||
className={classNames('sidebar-notification-btn', { active: isSidebarVisible() })}
|
||||
type="button"
|
||||
onClick={() => { toggleSidebar(); }}
|
||||
aria-label={intl.formatMessage(messages.openSidebarButton)}
|
||||
>
|
||||
{/* REV-2130 TODO: add logic for status "active" if red dot should display */}
|
||||
<NotificationIcon status="active" notificationColor="bg-danger-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
SidebarNotificationButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
toggleSidebar: PropTypes.func.isRequired,
|
||||
isSidebarVisible: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarNotificationButton);
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, initializeTestStore, screen, fireEvent,
|
||||
} from '../../setupTest';
|
||||
import SidebarNotificationButton from './SidebarNotificationButton';
|
||||
|
||||
describe('Sidebar Notification Button', () => {
|
||||
let mockData;
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
mockData = {
|
||||
toggleSidebar: () => {},
|
||||
isSidebarVisible: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders sidebar notification button with icon', async () => {
|
||||
const { container } = render(<SidebarNotificationButton {...mockData} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
const buttonIcon = container.querySelectorAll('svg');
|
||||
expect(buttonIcon).toHaveLength(1);
|
||||
|
||||
// REV-2130 TODO: update below test once the status=active or inactive is implemented
|
||||
expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles onClick event toggling the sidebar', async () => {
|
||||
const toggleSidebar = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleSidebar,
|
||||
};
|
||||
render(<SidebarNotificationButton {...testData} />);
|
||||
|
||||
const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i });
|
||||
expect(sidebarOpenButton).toBeInTheDocument();
|
||||
fireEvent.click(sidebarOpenButton);
|
||||
expect(toggleSidebar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ function CatalogSuggestion({ intl, variant }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="row w-100 mx-0 my-2 justify-content-center">
|
||||
<div className="row w-100 mx-0 my-2 justify-content-center" data-testid="catalog-suggestion">
|
||||
<div className="col col-md-8 p-4 bg-info-100 text-center">
|
||||
<FontAwesomeIcon icon={faSearch} style={{ width: '20px' }} />
|
||||
<FormattedMessage
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import CatalogSuggestion from './CatalogSuggestion';
|
||||
import CelebrationMobile from './assets/celebration_456x328.gif';
|
||||
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
||||
import certificate from '../../../generic/assets/edX_certificate.png';
|
||||
@@ -27,7 +26,7 @@ import UpgradeFootnote from './UpgradeFootnote';
|
||||
import SocialIcons from '../../social-share/SocialIcons';
|
||||
import { logClick, logVisit } from './utils';
|
||||
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
|
||||
import CourseRecommendations from './CourseRecommendationsExp/CourseRecommendations.exp';
|
||||
import CourseRecommendations from './CourseRecommendations';
|
||||
|
||||
const LINKEDIN_BLUE = '#2867B2';
|
||||
|
||||
@@ -63,10 +62,6 @@ function CourseCelebration({ intl }) {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
/** [WS-1681 experiment] */
|
||||
const [showWS1681, setShowWS1681] = useState(window.experiment__courseware_celebration_bShowWS1681);
|
||||
useEffect(() => { setShowWS1681(window.experiment__courseware_celebration_bShowWS1681); });
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const dashboardLink = <DashboardLink />;
|
||||
@@ -330,9 +325,7 @@ function CourseCelebration({ intl }) {
|
||||
/>
|
||||
))}
|
||||
{footnote}
|
||||
{ showWS1681 && <CourseRecommendations variant={visitEvent} />}
|
||||
{ !showWS1681 && <CatalogSuggestion variant={visitEvent} /> }
|
||||
|
||||
<CourseRecommendations variant={visitEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,7 +21,7 @@ jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Course Exit Pages', () => {
|
||||
let axiosMock;
|
||||
const store = initializeStore();
|
||||
let store;
|
||||
const defaultMetadata = Factory.build('courseMetadata', {
|
||||
user_has_passing_grade: true,
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
@@ -31,6 +31,9 @@ describe('Course Exit Pages', () => {
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
|
||||
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
|
||||
function setMetadata(attributes) {
|
||||
const courseMetadata = { ...defaultMetadata, ...attributes };
|
||||
@@ -43,9 +46,14 @@ describe('Course Exit Pages', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, defaultCourseBlocks);
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||||
Factory.build('courseRecommendations', {}, { numRecs: 2 }));
|
||||
axiosMock.onGet(enrollmentsUrl).reply(200, []);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
@@ -290,6 +298,61 @@ describe('Course Exit Pages', () => {
|
||||
expect(screen.queryByTestId('excluded-program-type')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course recommendations', () => {
|
||||
it('Displays recommendations if at least two are available', async () => {
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
const recommendationsTable = await screen.findByTestId('course-recommendations');
|
||||
expect(recommendationsTable).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('catalog-suggestion')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays the generic catalog suggestion if fewer than two recommendations are available', async () => {
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||||
Factory.build('courseRecommendations', {}, { numRecs: 1 }));
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
const catalogSuggestion = await screen.findByTestId('catalog-suggestion');
|
||||
expect(catalogSuggestion).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('course-recommendations')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Will not recommend a course in which the user is already enrolled', async () => {
|
||||
const initialRecommendations = Factory.build('courseRecommendations', {}, { numRecs: 2 });
|
||||
initialRecommendations.recommendations.push(
|
||||
Factory.build('courseRecommendation', { key: 'edX+EnrolledX', title: 'Already Enrolled' }),
|
||||
);
|
||||
initialRecommendations.recommendations.push(
|
||||
Factory.build('courseRecommendation', { key: 'edX+NotEnrolledX', title: 'Not Already Enrolled' }),
|
||||
);
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200, initialRecommendations);
|
||||
axiosMock.onGet(enrollmentsUrl).reply(200, [
|
||||
Factory.build('userEnrollment', '',
|
||||
{
|
||||
runKey: 'edX+EnrolledX+1T2021',
|
||||
}),
|
||||
]);
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
const recommendationsTable = await screen.findByTestId('course-recommendations');
|
||||
expect(recommendationsTable).toBeInTheDocument();
|
||||
expect(screen.queryByText('Already Enrolled')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Not Already Enrolled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Will not recommend the same course that the user just finished', async () => {
|
||||
// the uuid returned from the call to discovery is the uuid of the current course
|
||||
const initialRecommendations = Factory.build('courseRecommendations',
|
||||
{ uuid: 'my_uuid' },
|
||||
{ numRecs: 2 });
|
||||
initialRecommendations.recommendations.push(
|
||||
Factory.build('courseRecommendation', { uuid: 'my_uuid', title: 'Same Course' }),
|
||||
);
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200, initialRecommendations);
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
const recommendationsTable = await screen.findByTestId('course-recommendations');
|
||||
expect(recommendationsTable).toBeInTheDocument();
|
||||
expect(screen.queryByText('Same Course')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Non-passing Experience', () => {
|
||||
@@ -307,6 +370,7 @@ describe('Course Exit Pages', () => {
|
||||
const courseBlocks = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name,
|
||||
{ hasScheduledContent: true });
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
await fetchAndRender(<CourseInProgress />);
|
||||
expect(screen.getByText('More content is coming soon!')).toBeInTheDocument();
|
||||
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
FormattedMessage, injectIntl, intlShape, defineMessages,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Hyperlink, DataTable, CardView } from '@edx/paragon';
|
||||
import {
|
||||
Hyperlink, DataTable, CardView, Card,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import truncate from 'truncate-html';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import fetchCourseRecommendations from './data/thunks.exp';
|
||||
import { FAILED, LOADED, LOADING } from './data/slice.exp';
|
||||
import CatalogSuggestion from '../CatalogSuggestion';
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
import { logClick } from '../utils';
|
||||
import { useModel } from '../../../generic/model-store/hooks';
|
||||
import fetchCourseRecommendations from './data/thunks';
|
||||
import { FAILED, LOADED, LOADING } from './data/slice';
|
||||
import CatalogSuggestion from './CatalogSuggestion';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { logClick } from './utils';
|
||||
|
||||
const messages = defineMessages({
|
||||
recommendationsHeading: {
|
||||
@@ -48,8 +50,7 @@ const ListStyles = {
|
||||
conjunction: 'conjunction',
|
||||
};
|
||||
|
||||
// TODO: replace custom card (copied from Prospectus) with Paragon Card component
|
||||
function Card({
|
||||
function CourseCard({
|
||||
original: {
|
||||
title,
|
||||
image,
|
||||
@@ -74,24 +75,23 @@ function Card({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="discovery-card"
|
||||
role="group"
|
||||
aria-label={title}
|
||||
>
|
||||
<Hyperlink
|
||||
destination={marketingUrl}
|
||||
className="discovery-card-link"
|
||||
className="text-decoration-none"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="d-flex flex-column d-card-wrapper">
|
||||
<div className="d-card-hero">
|
||||
<img src={image.src} alt="" />
|
||||
</div>
|
||||
<div className="d-card-body">
|
||||
<h3 className="name-heading">
|
||||
{truncate(title, 70, { reserveLastWord: -1 })}
|
||||
</h3>
|
||||
<div className="provider">
|
||||
<Card style={{ width: '270px', height: '270px' }} className="discovery-card">
|
||||
<Card.Img variant="top" src={image.src} bsPrefix="d-card-hero" />
|
||||
<Card.Body>
|
||||
<Card.Title>
|
||||
<h3 className="h4 text-gray-700 font-weight-normal">
|
||||
{truncate(title, 70, { reserveLastWord: -1 })}
|
||||
</h3>
|
||||
</Card.Title>
|
||||
<div className="text-gray-500 small">
|
||||
<FormattedMessage
|
||||
id="courseCelebration.recommendations.card.schools.label"
|
||||
description="Screenreader label for the Schools and Partners running the course."
|
||||
@@ -104,23 +104,22 @@ function Card({
|
||||
)}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-card-footer">
|
||||
<div className="card-type">
|
||||
<FormattedMessage
|
||||
id="courseCelebration.recommendations.label"
|
||||
description="Label on a discovery-card that lets a user know that it is a course card"
|
||||
defaultMessage="Course"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
<footer className="pl-4 pb-2 x-small text-gray-500">
|
||||
<FormattedMessage
|
||||
id="courseCelebration.recommendations.label"
|
||||
description="Label on a discovery-card that lets a user know that it is a course card"
|
||||
defaultMessage="Course"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
CourseCard.propTypes = {
|
||||
original: PropTypes.shape({
|
||||
marketingUrl: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
@@ -135,7 +134,7 @@ Card.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const IntlCard = injectIntl(Card);
|
||||
const IntlCard = injectIntl(CourseCard);
|
||||
|
||||
function CourseRecommendations({ intl, variant }) {
|
||||
const { courseId, recommendationsStatus } = useSelector(state => ({ ...state.recommendations, ...state.courseware }));
|
||||
@@ -178,7 +177,7 @@ function CourseRecommendations({ intl, variant }) {
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="course-recommendations d-flex flex-column align-items-center">
|
||||
<div className="course-recommendations d-flex flex-column align-items-center" data-testid="course-recommendations">
|
||||
<h2 className="text-center mb-3">{intl.formatMessage(messages.recommendationsHeading)}</h2>
|
||||
<div className="mb-2 mt-3">
|
||||
<DataTable
|
||||
30
src/courseware/course/course-exit/CourseRecommendations.scss
Normal file
30
src/courseware/course/course-exit/CourseRecommendations.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
.course-recommendations {
|
||||
.pgn__data-table-wrapper {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
.pgn__card-grid {
|
||||
.row > div[class*="col-"] {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discovery-card {
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: 0 2px 4px 2px $gray-500
|
||||
}
|
||||
}
|
||||
|
||||
.d-card-hero-top {
|
||||
height: 102px;
|
||||
min-height: 102px;
|
||||
background-color: $gray-200;
|
||||
overflow: hidden;
|
||||
border: {
|
||||
radius: 3px 3px 0 0;
|
||||
bottom: 1px solid $success-100;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
$default-border-color: $info-300;
|
||||
.course-recommendations {
|
||||
.pgn__data-table-wrapper {
|
||||
border: 0;
|
||||
.pgn__card-grid {
|
||||
.row > div[class*="col-"] {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discovery-card {
|
||||
min-width: 270px;
|
||||
max-width: 270px;
|
||||
width: 270px;
|
||||
height: 270px;
|
||||
position: relative;
|
||||
border-bottom: 3px solid $default-border-color;
|
||||
background-color: $white;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
&.custom-link {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.d-card-wrapper {
|
||||
height: 270px;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.3);
|
||||
border: {
|
||||
color: $primary-200;
|
||||
width: 1px;
|
||||
radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.discovery-card-link {
|
||||
text-decoration: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: 0;
|
||||
outline: none;
|
||||
|
||||
.d-card-wrapper {
|
||||
box-shadow: 0 2px 4px 2px $gray-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.d-card-hero {
|
||||
height: 102px;
|
||||
background-color: $gray-200;
|
||||
overflow: hidden;
|
||||
border: {
|
||||
radius: 3px 3px 0 0;
|
||||
bottom: 1px solid $success-100;
|
||||
}
|
||||
}
|
||||
|
||||
.d-card-body {
|
||||
padding: 28px 20px 33px;
|
||||
}
|
||||
|
||||
.d-card-footer {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.name-heading {
|
||||
height: auto;
|
||||
line-height: 1.15;
|
||||
color: $gray-700;
|
||||
font: {
|
||||
family: $font-family-sans-serif;
|
||||
size: 1.25rem;
|
||||
weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.provider {
|
||||
line-height: 0.86;
|
||||
color: $gray-500;
|
||||
margin-bottom: 20px;
|
||||
font: {
|
||||
family: $font-family-sans-serif;
|
||||
size: 0.875rem;
|
||||
weight: $font-weight-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.card-type {
|
||||
height: 20px;
|
||||
line-height: 1.67;
|
||||
letter-spacing: 0.2px;
|
||||
color: $gray-500;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
font: {
|
||||
family: $font-family-sans-serif;
|
||||
size: 0.75rem;
|
||||
weight: $font-weight-normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
fetchCourseRecommendationsFailure,
|
||||
fetchCourseRecommendationsRequest,
|
||||
fetchCourseRecommendationsSuccess,
|
||||
} from './slice.exp';
|
||||
import getCourseRecommendations from './api.exp';
|
||||
import { updateModel } from '../../../../../generic/model-store';
|
||||
} from './slice';
|
||||
import getCourseRecommendations from './api';
|
||||
import { updateModel } from '../../../../generic/model-store';
|
||||
|
||||
export default function fetchCourseRecommendations(courseKey, courseId) {
|
||||
return async (dispatch) => {
|
||||
@@ -1,33 +1,33 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
sidebarNotification: {
|
||||
id: 'sidebar.notification.container',
|
||||
defaultMessage: 'Sidebar notification',
|
||||
description: 'Sidebar notification section container',
|
||||
notificationTray: {
|
||||
id: 'notification.tray.container',
|
||||
defaultMessage: 'Notification tray',
|
||||
description: 'Notification tray container',
|
||||
},
|
||||
openSidebarButton: {
|
||||
id: 'sidebar.open.button',
|
||||
defaultMessage: 'Show sidebar notification',
|
||||
description: 'Button to open the sidebar and show notifications',
|
||||
openNotificationTrigger: {
|
||||
id: 'notification.open.button',
|
||||
defaultMessage: 'Show notification tray',
|
||||
description: 'Button to open the notification tray and show notifications',
|
||||
},
|
||||
closeSidebarButton: {
|
||||
id: 'sidebar.close.button',
|
||||
defaultMessage: 'Close sidebar notification',
|
||||
closeNotificationTrigger: {
|
||||
id: 'notification.close.button',
|
||||
defaultMessage: 'Close notification tray',
|
||||
description: 'Button for the learner to close the sidebar',
|
||||
},
|
||||
responsiveCloseSidebar: {
|
||||
id: 'sidebar.responsive.close.button',
|
||||
responsiveCloseNotificationTray: {
|
||||
id: 'responsive.close.notification',
|
||||
defaultMessage: 'Back to course',
|
||||
description: 'Responsive button for the learner to go back to course and close the sidebar',
|
||||
description: 'Responsive button to go back to course and close the notification tray',
|
||||
},
|
||||
notificationTitle: {
|
||||
id: 'sidebar.notification.title',
|
||||
id: 'notification.tray.title',
|
||||
defaultMessage: 'Notifications',
|
||||
description: 'Title text displayed for sidebar notifications',
|
||||
description: 'Title text displayed for the notification tray',
|
||||
},
|
||||
noNotificationsMessage: {
|
||||
id: 'sidebar.notification.no.message',
|
||||
id: 'notification.tray.no.message',
|
||||
defaultMessage: 'You have no new notifications at this time.',
|
||||
description: 'Text displayed when the learner has no notifications',
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ import CourseLicense from '../course-license';
|
||||
import messages from './messages';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import SequenceContent from './SequenceContent';
|
||||
import Sidebar from '../Sidebar';
|
||||
import SidebarNotificationButton from '../SidebarNotificationButton';
|
||||
import NotificationTray from '../NotificationTray';
|
||||
import NotificationTrigger from '../NotificationTrigger';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { isMobile } from '../../../experiments/mm-p2p/utils';
|
||||
@@ -37,10 +37,9 @@ function Sequence({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
intl,
|
||||
toggleSidebar,
|
||||
sidebarVisible,
|
||||
isSidebarVisible,
|
||||
isValuePropCookieSet,
|
||||
toggleNotificationTray,
|
||||
notificationTrayVisible,
|
||||
isNotificationTrayVisible,
|
||||
mmp2p,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
@@ -49,7 +48,7 @@ function Sequence({
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const specialExamsEnabledWaffleFlag = useSelector(state => state.courseware.specialExamsEnabledWaffleFlag);
|
||||
const proctoredExamsEnabledWaffleFlag = useSelector(state => state.courseware.proctoredExamsEnabledWaffleFlag);
|
||||
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
const shouldDisplayNotificationTrigger = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const handleNext = () => {
|
||||
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
|
||||
@@ -167,7 +166,7 @@ function Sequence({
|
||||
|
||||
const defaultContent = (
|
||||
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
||||
<div className={classNames('sequence', { 'position-relative': shouldDisplaySidebarButton })} style={{ width: '100%' }}>
|
||||
<div className={classNames('sequence', { 'position-relative': shouldDisplayNotificationTrigger })} style={{ width: '100%' }}>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
@@ -189,13 +188,12 @@ function Sequence({
|
||||
handlePrevious();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
isValuePropCookieSet={isValuePropCookieSet}
|
||||
/>
|
||||
|
||||
{isValuePropCookieSet && shouldDisplaySidebarButton ? (
|
||||
<SidebarNotificationButton
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
{shouldDisplayNotificationTrigger ? (
|
||||
<NotificationTrigger
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -226,10 +224,10 @@ function Sequence({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sidebarVisible ? (
|
||||
<Sidebar
|
||||
toggleSidebar={toggleSidebar}
|
||||
sidebarVisible={sidebarVisible}
|
||||
{notificationTrayVisible ? (
|
||||
<NotificationTray
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/>
|
||||
) : null }
|
||||
|
||||
@@ -269,10 +267,9 @@ Sequence.propTypes = {
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
toggleSidebar: PropTypes.func,
|
||||
sidebarVisible: PropTypes.bool,
|
||||
isSidebarVisible: PropTypes.func,
|
||||
isValuePropCookieSet: PropTypes.bool,
|
||||
toggleNotificationTray: PropTypes.func,
|
||||
notificationTrayVisible: PropTypes.bool,
|
||||
isNotificationTrayVisible: PropTypes.func,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
@@ -291,10 +288,9 @@ Sequence.propTypes = {
|
||||
Sequence.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
toggleSidebar: null,
|
||||
sidebarVisible: null,
|
||||
isSidebarVisible: null,
|
||||
isValuePropCookieSet: null,
|
||||
toggleNotificationTray: null,
|
||||
notificationTrayVisible: null,
|
||||
isNotificationTrayVisible: null,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
} from '../../../setupTest';
|
||||
import Sequence from './Sequence';
|
||||
import { fetchSequenceFailure } from '../../data/slice';
|
||||
import useWindowSize from '../../../generic/tabs/useWindowSize';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('../../../generic/tabs/useWindowSize');
|
||||
useWindowSize.mockReturnValue({ width: 1200 });
|
||||
|
||||
describe('Sequence', () => {
|
||||
let mockData;
|
||||
@@ -28,7 +31,6 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: () => {},
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
sidebarVisible: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -130,16 +132,6 @@ describe('Sequence', () => {
|
||||
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
|
||||
});
|
||||
|
||||
it('renders sidebar in sequence', async () => {
|
||||
const testData = {
|
||||
...mockData,
|
||||
sidebarVisible: true,
|
||||
};
|
||||
|
||||
render(<Sequence {...testData} />);
|
||||
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
let testStore;
|
||||
const sequenceBlocks = [Factory.build(
|
||||
@@ -387,4 +379,22 @@ describe('Sequence', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
const testData = {
|
||||
...mockData,
|
||||
notificationTrayVisible: true,
|
||||
};
|
||||
render(<Sequence {...testData} />);
|
||||
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render notification tray in sequence by default if in responsive view', async () => {
|
||||
useWindowSize.mockReturnValue({ width: 991 });
|
||||
const { container } = render(<Sequence {...mockData} />);
|
||||
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
|
||||
expect(container).not.toHaveClass('notification-tray-container');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,15 @@ function useLoadBearingHook(id) {
|
||||
}, [id]);
|
||||
}
|
||||
|
||||
export function sendUrlHashToFrame(frame) {
|
||||
const { hash } = window.location;
|
||||
if (hash) {
|
||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||
// hash within the iframe.
|
||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
function Unit({
|
||||
courseId,
|
||||
format,
|
||||
@@ -89,6 +98,7 @@ function Unit({
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
@@ -98,13 +108,21 @@ function Unit({
|
||||
} = course;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (userNeedsIntegritySignature && unit.graded) {
|
||||
setShouldDisplayHonorCode(true);
|
||||
} else {
|
||||
setShouldDisplayHonorCode(false);
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
// We use this ref so that we can hold a reference to the currently active event listener.
|
||||
const messageEventListenerRef = useRef(null);
|
||||
useEffect(() => {
|
||||
sendUrlHashToFrame(document.getElementById('unit-iframe'));
|
||||
function receiveMessage(event) {
|
||||
const { type, payload } = event.data;
|
||||
if (type === 'plugin.resize') {
|
||||
@@ -120,8 +138,8 @@ function Unit({
|
||||
setModalOptions(payload);
|
||||
} else if (event.data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scroll to another location on the page.
|
||||
window.scrollTo(0, event.data.offset);
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, event.data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}
|
||||
// If we currently have an event listener, remove it.
|
||||
@@ -160,7 +178,7 @@ function Unit({
|
||||
{ mmp2p.meta.showLock && (
|
||||
<MMP2PLockPaywall options={mmp2p} />
|
||||
)}
|
||||
{!mmp2p.meta.blockContent && unit.graded && userNeedsIntegritySignature && (
|
||||
{!mmp2p.meta.blockContent && shouldDisplayHonorCode && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
@@ -172,7 +190,7 @@ function Unit({
|
||||
</Suspense>
|
||||
)}
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{!mmp2p.meta.blockContent && !userNeedsIntegritySignature && !hasLoaded && (
|
||||
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
@@ -203,7 +221,7 @@ function Unit({
|
||||
/>
|
||||
)}
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{ !mmp2p.meta.blockContent && !userNeedsIntegritySignature && (
|
||||
{ !mmp2p.meta.blockContent && !shouldDisplayHonorCode && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id="unit-iframe"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Factory } from 'rosie';
|
||||
import {
|
||||
initializeTestStore, loadUnit, messageEvent, render, screen, waitFor,
|
||||
} from '../../../setupTest';
|
||||
import Unit from './Unit';
|
||||
import Unit, { sendUrlHashToFrame } from './Unit';
|
||||
|
||||
describe('Unit', () => {
|
||||
let mockData;
|
||||
@@ -11,21 +11,32 @@ describe('Unit', () => {
|
||||
'courseMetadata',
|
||||
{ content_type_gating_enabled: true },
|
||||
);
|
||||
const unitBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'problem', graded: 'true' },
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
{
|
||||
type: 'vertical',
|
||||
contains_content_type_gated_content: true,
|
||||
bookmarked: true,
|
||||
graded: true,
|
||||
},
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
const [unit, unitThatContainsGatedContent] = unitBlocks;
|
||||
const courseMetadataNeedsSignature = Factory.build(
|
||||
'courseMetadata',
|
||||
{ user_needs_integrity_signature: true },
|
||||
);
|
||||
const unitBlocks = [
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'problem', graded: 'true' },
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
{
|
||||
type: 'vertical',
|
||||
contains_content_type_gated_content: true,
|
||||
bookmarked: true,
|
||||
graded: true,
|
||||
},
|
||||
{ courseId: courseMetadata.id },
|
||||
),
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'problem', graded: false },
|
||||
{ courseId: courseMetadata.id },
|
||||
),
|
||||
];
|
||||
const [unit, unitThatContainsGatedContent, ungradedUnit] = unitBlocks;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
@@ -38,7 +49,6 @@ describe('Unit', () => {
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<Unit {...mockData} />);
|
||||
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
const renderedUnit = screen.getByTitle(unit.display_name);
|
||||
expect(renderedUnit).toHaveAttribute('height', String(0));
|
||||
@@ -49,23 +59,32 @@ describe('Unit', () => {
|
||||
|
||||
it('renders proper message for gated content', () => {
|
||||
render(<Unit {...mockData} id={unitThatContainsGatedContent.id} />);
|
||||
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays HonorCode when userNeedsIntegritySignature is true', async () => {
|
||||
const signatureMetadata = Factory.build(
|
||||
'courseMetadata',
|
||||
{ user_needs_integrity_signature: true },
|
||||
);
|
||||
it('does not display HonorCode for ungraded units', async () => {
|
||||
const signatureStore = await initializeTestStore(
|
||||
{ courseMetadata: signatureMetadata, unitBlocks },
|
||||
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
|
||||
false,
|
||||
);
|
||||
const signatureData = {
|
||||
id: ungradedUnit.id,
|
||||
courseId: courseMetadataNeedsSignature.id,
|
||||
format: 'Homework',
|
||||
};
|
||||
render(<Unit {...signatureData} />, { store: signatureStore });
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays HonorCode for graded units if user needs integrity signature', async () => {
|
||||
const signatureStore = await initializeTestStore(
|
||||
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
|
||||
false,
|
||||
);
|
||||
const signatureData = {
|
||||
id: unit.id,
|
||||
courseId: signatureMetadata.id,
|
||||
courseId: courseMetadataNeedsSignature.id,
|
||||
format: 'Homework',
|
||||
};
|
||||
render(<Unit {...signatureData} />, { store: signatureStore });
|
||||
@@ -75,7 +94,6 @@ describe('Unit', () => {
|
||||
it('handles receiving MessageEvent', async () => {
|
||||
render(<Unit {...mockData} />);
|
||||
loadUnit();
|
||||
|
||||
// Loading message is gone now.
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
// Iframe's height is set via message.
|
||||
@@ -125,4 +143,37 @@ describe('Unit', () => {
|
||||
{ timeout: 100 },
|
||||
)).rejects.toThrowError(/Expected the element to have attribute/);
|
||||
});
|
||||
|
||||
it('scrolls to correct place onLoad', () => {
|
||||
document.body.innerHTML = "<iframe id='unit-iframe' />";
|
||||
|
||||
const mockHashCheck = jest.fn(frameVar => sendUrlHashToFrame(frameVar));
|
||||
const frame = document.getElementById('unit-iframe');
|
||||
const originalWindow = { ...window };
|
||||
const windowSpy = jest.spyOn(global, 'window', 'get');
|
||||
windowSpy.mockImplementation(() => ({
|
||||
...originalWindow,
|
||||
location: {
|
||||
...originalWindow.location,
|
||||
hash: '#test',
|
||||
},
|
||||
}));
|
||||
const messageSpy = jest.spyOn(frame.contentWindow, 'postMessage');
|
||||
messageSpy.mockImplementation(() => ({ hashName: originalWindow.location.hash }));
|
||||
mockHashCheck(frame);
|
||||
|
||||
expect(mockHashCheck).toHaveBeenCalled();
|
||||
expect(messageSpy).toHaveBeenCalled();
|
||||
|
||||
windowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls useEffect and checkForHash', () => {
|
||||
const mockHashCheck = jest.fn(() => sendUrlHashToFrame());
|
||||
const effectSpy = jest.spyOn(React, 'useEffect');
|
||||
effectSpy.mockImplementation(() => mockHashCheck());
|
||||
render(<Unit {...mockData} />);
|
||||
expect(React.useEffect).toHaveBeenCalled();
|
||||
expect(mockHashCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ function SequenceNavigation({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
goToCourseExitPage,
|
||||
isValuePropCookieSet,
|
||||
mmp2p,
|
||||
}) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
@@ -40,7 +39,7 @@ function SequenceNavigation({
|
||||
sequence.gatedContent !== undefined && sequence.gatedContent.gated
|
||||
) : undefined;
|
||||
|
||||
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
const shouldDisplayNotificationTrigger = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const renderUnitButtons = () => {
|
||||
if (isLocked) {
|
||||
@@ -70,15 +69,15 @@ function SequenceNavigation({
|
||||
const disabled = isLastUnit && !exitActive;
|
||||
return (
|
||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={ChevronRight}>
|
||||
{isValuePropCookieSet && shouldDisplaySidebarButton ? null : buttonText}
|
||||
{shouldDisplayNotificationTrigger ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
<nav className={classNames('sequence-navigation', className)} style={{ width: isValuePropCookieSet && shouldDisplaySidebarButton ? '90%' : null }}>
|
||||
<nav className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTrigger ? '90%' : null }}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={ChevronLeft}>
|
||||
{isValuePropCookieSet && shouldDisplaySidebarButton ? null : intl.formatMessage(messages.previousButton)}
|
||||
{shouldDisplayNotificationTrigger ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderUnitButtons()}
|
||||
{renderNextButton()}
|
||||
@@ -98,7 +97,6 @@ SequenceNavigation.propTypes = {
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
isValuePropCookieSet: PropTypes.bool,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
@@ -110,7 +108,6 @@ SequenceNavigation.propTypes = {
|
||||
SequenceNavigation.defaultProps = {
|
||||
className: null,
|
||||
unitId: null,
|
||||
isValuePropCookieSet: null,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('Sequence Navigation', () => {
|
||||
expect(testData.onNavigate).not.toHaveBeenCalled();
|
||||
// TODO: Not sure if this is working as expected, because the `contentType="lock"` will be overridden by the value
|
||||
// from Redux. To make this provide a `fa-icon` lock we could introduce something like `overriddenContentType`.
|
||||
expect(unitButton.firstChild.firstChild).toHaveClass('fa-edit');
|
||||
expect(unitButton.firstChild).toHaveClass('fa-edit');
|
||||
});
|
||||
|
||||
it('renders correctly and handles unit button clicks', () => {
|
||||
|
||||
@@ -59,4 +59,5 @@ Factory.define('courseMetadata')
|
||||
user_needs_integrity_signature: false,
|
||||
is_mfe_special_exams_enabled: false,
|
||||
is_mfe_proctored_exams_enabled: false,
|
||||
recommendations: null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('courseRecommendations')
|
||||
.option('host', '')
|
||||
.option('numRecs', '', 4)
|
||||
.sequence('uuid', (i) => `a-uuid-${i}`)
|
||||
.attr('recommendations', ['numRecs'], (numRecs) => {
|
||||
const recs = [];
|
||||
for (let i = 0; i < numRecs; i++) {
|
||||
recs.push(Factory.build('courseRecommendation'));
|
||||
}
|
||||
return recs;
|
||||
});
|
||||
|
||||
Factory.define('courseRecommendation')
|
||||
.sequence('key', (i) => `edX+DemoX${i}`)
|
||||
.sequence('uuid', (i) => `abcd-${i}`)
|
||||
.attrs({
|
||||
title: 'DemoX',
|
||||
owners: [
|
||||
{
|
||||
uuid: '',
|
||||
key: 'edX',
|
||||
},
|
||||
],
|
||||
image: {
|
||||
src: '',
|
||||
},
|
||||
})
|
||||
.attr('course_run_keys', ['key'], (key) => (
|
||||
[`${key}+1T2021`]
|
||||
))
|
||||
.attr('url_slug', ['key'], (key) => key)
|
||||
.attr('marketing_url', ['url_slug'], (urlSlug) => `https://www.edx.org/course/${urlSlug}`);
|
||||
|
||||
Factory.define('userEnrollment')
|
||||
.option('runKey')
|
||||
.attr('course_details', ['runKey'], (runKey) => (runKey ? { course_id: runKey } : { course_id: 'edX+EnrolledX' }));
|
||||
@@ -1,2 +1,3 @@
|
||||
import './courseMetadata.factory';
|
||||
import './sequenceMetadata.factory';
|
||||
import './courseRecommendations.factory';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('learningSequencesOutline')
|
||||
.option('courseId', (courseId) => {
|
||||
if (courseId) {
|
||||
return courseId;
|
||||
}
|
||||
throw new Error('courseId must be specified for learningSequencesOutline factory.');
|
||||
})
|
||||
.attrs({
|
||||
outline: {
|
||||
sequences: {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function buildEmptyOutline(courseId) {
|
||||
return Factory.build(
|
||||
'learningSequencesOutline',
|
||||
{},
|
||||
{ courseId },
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSimpleOutline(courseId, sequenceBlocks) {
|
||||
return Factory.build(
|
||||
'learningSequencesOutline',
|
||||
{
|
||||
outline: {
|
||||
sequences: Object.fromEntries(
|
||||
sequenceBlocks.map(({ id }) => [id, {}]),
|
||||
),
|
||||
},
|
||||
},
|
||||
{ courseId },
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { getTimeOffsetMillis } from '../../course-home/data/api';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
export function normalizeBlocks(courseId, blocks) {
|
||||
@@ -10,6 +11,7 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
sequences: {},
|
||||
units: {},
|
||||
};
|
||||
|
||||
Object.values(blocks).forEach(block => {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
@@ -105,6 +107,29 @@ export async function getCourseBlocks(courseId) {
|
||||
return normalizeBlocks(courseId, data.blocks);
|
||||
}
|
||||
|
||||
// Returns the output of the Learning Sequences API, or null if that API is not
|
||||
// currently available for this user in this course.
|
||||
export async function getLearningSequencesOutline(courseId) {
|
||||
const outlineUrl = new URL(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/${courseId}`);
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(outlineUrl.href, {});
|
||||
return data;
|
||||
} catch (error) {
|
||||
// This is not a critical API to use at the moment. If it errors for any
|
||||
// reason, just send back a null so the higher layers know to ignore it.
|
||||
if (error.response) {
|
||||
if (error.response.status === 403) {
|
||||
logInfo('Learning Sequences API not enabled for this user.');
|
||||
} else {
|
||||
logInfo(`Unexpected error calling Learning Sequences API (${error.response.status}). Ignoring.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTabUrls(id, tabs) {
|
||||
// If api doesn't return the mfe base url, change tab url to point back to LMS
|
||||
return tabs.map((tab) => {
|
||||
@@ -117,51 +142,55 @@ function normalizeTabUrls(id, tabs) {
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata) {
|
||||
const requestTime = Date.now();
|
||||
const responseTime = requestTime;
|
||||
const { data, headers } = metadata;
|
||||
return {
|
||||
accessExpiration: camelCaseObject(metadata.access_expiration),
|
||||
canShowUpgradeSock: metadata.can_show_upgrade_sock,
|
||||
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
|
||||
id: metadata.id,
|
||||
title: metadata.name,
|
||||
number: metadata.number,
|
||||
offer: camelCaseObject(metadata.offer),
|
||||
org: metadata.org,
|
||||
enrollmentStart: metadata.enrollment_start,
|
||||
enrollmentEnd: metadata.enrollment_end,
|
||||
end: metadata.end,
|
||||
start: metadata.start,
|
||||
enrollmentMode: metadata.enrollment.mode,
|
||||
isEnrolled: metadata.enrollment.is_active,
|
||||
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
|
||||
canViewLegacyCourseware: metadata.can_view_legacy_courseware,
|
||||
originalUserIsStaff: metadata.original_user_is_staff,
|
||||
isStaff: metadata.is_staff,
|
||||
license: metadata.license,
|
||||
verifiedMode: camelCaseObject(metadata.verified_mode),
|
||||
tabs: normalizeTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
|
||||
userTimezone: metadata.user_timezone,
|
||||
showCalculator: metadata.show_calculator,
|
||||
notes: camelCaseObject(metadata.notes),
|
||||
marketingUrl: metadata.marketing_url,
|
||||
celebrations: camelCaseObject(metadata.celebrations),
|
||||
userHasPassingGrade: metadata.user_has_passing_grade,
|
||||
courseExitPageIsActive: metadata.course_exit_page_is_active,
|
||||
certificateData: camelCaseObject(metadata.certificate_data),
|
||||
verifyIdentityUrl: metadata.verify_identity_url,
|
||||
verificationStatus: metadata.verification_status,
|
||||
linkedinAddToProfileUrl: metadata.linkedin_add_to_profile_url,
|
||||
relatedPrograms: camelCaseObject(metadata.related_programs),
|
||||
userNeedsIntegritySignature: metadata.user_needs_integrity_signature,
|
||||
specialExamsEnabledWaffleFlag: metadata.is_mfe_special_exams_enabled,
|
||||
proctoredExamsEnabledWaffleFlag: metadata.is_mfe_proctored_exams_enabled,
|
||||
accessExpiration: camelCaseObject(data.access_expiration),
|
||||
canShowUpgradeSock: data.can_show_upgrade_sock,
|
||||
contentTypeGatingEnabled: data.content_type_gating_enabled,
|
||||
id: data.id,
|
||||
title: data.name,
|
||||
number: data.number,
|
||||
offer: camelCaseObject(data.offer),
|
||||
org: data.org,
|
||||
enrollmentStart: data.enrollment_start,
|
||||
enrollmentEnd: data.enrollment_end,
|
||||
end: data.end,
|
||||
start: data.start,
|
||||
enrollmentMode: data.enrollment.mode,
|
||||
isEnrolled: data.enrollment.is_active,
|
||||
canLoadCourseware: camelCaseObject(data.can_load_courseware),
|
||||
canViewLegacyCourseware: data.can_view_legacy_courseware,
|
||||
originalUserIsStaff: data.original_user_is_staff,
|
||||
isStaff: data.is_staff,
|
||||
license: data.license,
|
||||
verifiedMode: camelCaseObject(data.verified_mode),
|
||||
tabs: normalizeTabUrls(data.id, camelCaseObject(data.tabs)),
|
||||
userTimezone: data.user_timezone,
|
||||
showCalculator: data.show_calculator,
|
||||
notes: camelCaseObject(data.notes),
|
||||
marketingUrl: data.marketing_url,
|
||||
celebrations: camelCaseObject(data.celebrations),
|
||||
userHasPassingGrade: data.user_has_passing_grade,
|
||||
courseExitPageIsActive: data.course_exit_page_is_active,
|
||||
certificateData: camelCaseObject(data.certificate_data),
|
||||
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
|
||||
verifyIdentityUrl: data.verify_identity_url,
|
||||
verificationStatus: data.verification_status,
|
||||
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
|
||||
relatedPrograms: camelCaseObject(data.related_programs),
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
|
||||
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseMetadata(courseId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeMetadata(data);
|
||||
const metadata = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeMetadata(metadata);
|
||||
}
|
||||
|
||||
function normalizeSequenceMetadata(sequence) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as thunks from './thunks';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
|
||||
import { buildSimpleCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
|
||||
import { buildEmptyOutline, buildSimpleOutline } from './__factories__/learningSequencesOutline.factory';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
@@ -19,6 +20,7 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
const sequenceBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence`;
|
||||
|
||||
// building minimum set of api responses to test all thunks
|
||||
@@ -30,6 +32,8 @@ describe('Data layer integration tests', () => {
|
||||
{},
|
||||
{ courseId, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
||||
);
|
||||
const emptyOutline = buildEmptyOutline(courseId);
|
||||
const simpleOutline = buildSimpleOutline(courseId, sequenceBlocks);
|
||||
|
||||
let courseUrl = `${courseBaseUrl}/${courseId}`;
|
||||
courseUrl = appendBrowserTimezoneToUrl(courseUrl);
|
||||
@@ -51,6 +55,7 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fail to fetch course and blocks if request error happens', async () => {
|
||||
axiosMock.onGet(courseUrl).networkError();
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).networkError();
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
@@ -70,12 +75,12 @@ describe('Data layer integration tests', () => {
|
||||
const forbiddenCourseBlocks = Factory.build('courseBlocks', {
|
||||
courseId: forbiddenCourseMetadata.id,
|
||||
});
|
||||
|
||||
let forbiddenCourseUrl = `${courseBaseUrl}/${forbiddenCourseMetadata.id}`;
|
||||
forbiddenCourseUrl = appendBrowserTimezoneToUrl(forbiddenCourseUrl);
|
||||
|
||||
axiosMock.onGet(forbiddenCourseUrl).reply(200, forbiddenCourseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, forbiddenCourseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
await executeThunk(thunks.fetchCourse(forbiddenCourseMetadata.id), store.dispatch);
|
||||
|
||||
@@ -90,6 +95,7 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
@@ -103,6 +109,50 @@ describe('Data layer integration tests', () => {
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].canLoadCourseware).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata; filtering has no effect', async () => {
|
||||
// Very similar to previous test, but pass back an outline for filtering
|
||||
// (even though it won't actually filter down in this case).
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, simpleOutline);
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].canLoadCourseware).not.toBeUndefined();
|
||||
expect(state.models.sequences.length === 1);
|
||||
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 1));
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata; filtering removes sequence', async () => {
|
||||
// Very similar to previous test, but pass back an outline for filtering
|
||||
// (even though it won't actually filter down in this case).
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, emptyOutline);
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseware.courseStatus).toEqual('loaded');
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].canLoadCourseware).not.toBeUndefined();
|
||||
expect(state.models.sequences === null);
|
||||
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchSequence', () => {
|
||||
@@ -134,6 +184,7 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
axiosMock.onGet(sequenceUrl).reply(200, sequenceMetadata);
|
||||
|
||||
// setting course with blocks before sequence to check that blocks receive
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
postSequencePosition,
|
||||
getCourseMetadata,
|
||||
getCourseBlocks,
|
||||
getLearningSequencesOutline,
|
||||
getSequenceMetadata,
|
||||
postIntegritySignature,
|
||||
} from './api';
|
||||
@@ -22,13 +23,27 @@ import {
|
||||
fetchSequenceFailure,
|
||||
} from './slice';
|
||||
|
||||
// Make a copy of the sectionData and return it, but with the sequences filtered
|
||||
// down to only those sequences in allowedSequences
|
||||
function filterSequencesFromSection(sectionData, allowedSequences) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(sectionData).map(
|
||||
([key, value]) => [
|
||||
key,
|
||||
(key === 'sequenceIds') ? value.filter(seqId => seqId in allowedSequences) : value,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseMetadata(courseId),
|
||||
getCourseBlocks(courseId),
|
||||
]).then(([courseMetadataResult, courseBlocksResult]) => {
|
||||
getLearningSequencesOutline(courseId),
|
||||
]).then(([courseMetadataResult, courseBlocksResult, learningSequencesOutlineResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: 'coursewareMeta',
|
||||
@@ -47,6 +62,28 @@ export function fetchCourse(courseId) {
|
||||
courses, sections, sequences, units,
|
||||
} = courseBlocksResult.value;
|
||||
|
||||
// Filter the data we get from the Course Blocks API using the data we
|
||||
// get back from the Learning Sequences API (which knows to hide certain
|
||||
// sequences that users shouldn't see).
|
||||
//
|
||||
// This is temporary – all this data should come from Learning Sequences
|
||||
// soon.
|
||||
let filteredSections = sections;
|
||||
let filteredSequences = sequences;
|
||||
if (learningSequencesOutlineResult.value) {
|
||||
const allowedSequences = learningSequencesOutlineResult.value.outline.sequences;
|
||||
filteredSequences = Object.fromEntries(
|
||||
Object.entries(sequences).filter(
|
||||
([blockId]) => blockId in allowedSequences,
|
||||
),
|
||||
);
|
||||
filteredSections = Object.fromEntries(
|
||||
Object.entries(sections).map(
|
||||
([blockId, sectionData]) => [blockId, filterSequencesFromSection(sectionData, allowedSequences)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// This updates the course with a sectionIds array from the blocks data.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'coursewareMeta',
|
||||
@@ -54,12 +91,12 @@ export function fetchCourse(courseId) {
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
modelsMap: filteredSections,
|
||||
}));
|
||||
// We update for sequences and units because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
modelsMap: filteredSequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
|
||||
@@ -108,9 +108,9 @@ const initHomeMMP2P = (courseId) => {
|
||||
const setMMP2POptions = createWindowStateSetter(_setMMP2POptions, MMP2PKeys.state);
|
||||
const setMMP2PAccess = createWindowStateSetter(_setMMP2PAccess, MMP2PKeys.access);
|
||||
|
||||
const loadAccess = () => {
|
||||
const { accessExpiration, verifiedMode } = useModel('coursewareMeta', courseId);
|
||||
const { accessExpiration, verifiedMode } = useModel('outline', courseId);
|
||||
|
||||
const loadAccess = () => {
|
||||
if (accessExpiration !== null && accessExpiration !== undefined) {
|
||||
setMMP2PAccess({
|
||||
isAudit: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user