Compare commits
43 Commits
open-relea
...
aj/MICROBA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7680871ca2 | ||
|
|
013e7bd9a1 | ||
|
|
059525c729 | ||
|
|
b9448d917a | ||
|
|
e18d6f0dae | ||
|
|
6fa6de4543 | ||
|
|
d0bcb19754 | ||
|
|
0f69ed5502 | ||
|
|
039d761a27 | ||
|
|
e2dd081d44 | ||
|
|
7e75671618 | ||
|
|
5ca10042c8 | ||
|
|
64979ecaf0 | ||
|
|
0175c4cf27 | ||
|
|
531f6d96ae | ||
|
|
997be712f1 | ||
|
|
7e5dacf68d | ||
|
|
73fa56d401 | ||
|
|
e46977f50d | ||
|
|
28e1f6f65a | ||
|
|
6c257271bb | ||
|
|
36f567c834 | ||
|
|
c6627a0854 | ||
|
|
608db6d423 | ||
|
|
72168b56f8 | ||
|
|
5af20067b8 | ||
|
|
c8ae544c8b | ||
|
|
28fddc5550 | ||
|
|
ef635b2a9b | ||
|
|
ce69d57dc8 | ||
|
|
43aa6291c3 | ||
|
|
e9f63674ca | ||
|
|
41b97ba638 | ||
|
|
8a7c61b64a | ||
|
|
34dbcb7ea6 | ||
|
|
ca8122686b | ||
|
|
4472541008 | ||
|
|
e5c8dad319 | ||
|
|
3e82152ae7 | ||
|
|
4d2bd81bf0 | ||
|
|
aca45fb26e | ||
|
|
d13bb04648 | ||
|
|
8dc7593780 |
177
package-lock.json
generated
177
package-lock.json
generated
@@ -1413,9 +1413,9 @@
|
||||
}
|
||||
},
|
||||
"@edx/paragon": {
|
||||
"version": "13.17.3",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-13.17.3.tgz",
|
||||
"integrity": "sha512-fUjrfNmeWIpEsroK0JuajIBHHh0BIvZTnBusTRqzvl5fFivNuhEdcG33oEZSVvfyRYtCgtnWmSRbvN5vGhjK6g==",
|
||||
"version": "14.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-14.8.0.tgz",
|
||||
"integrity": "sha512-ZCT4bur0ZlwI+UrzYcSRU0Vo9rBbSszbXrCrCeA5aZV9/xiwjoVJcMGxhWAMEfk5n9/R/bXBakcxc2Z+PjBEaQ==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||
@@ -1428,7 +1428,7 @@
|
||||
"font-awesome": "^4.7.0",
|
||||
"mailto-link": "^1.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-bootstrap": "^1.2.2",
|
||||
"react-bootstrap": "^1.3.0",
|
||||
"react-focus-on": "^3.5.0",
|
||||
"react-popper": "^2.2.4",
|
||||
"react-proptype-conditional-require": "^1.0.4",
|
||||
@@ -2312,9 +2312,9 @@
|
||||
}
|
||||
},
|
||||
"@popperjs/core": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.1.tgz",
|
||||
"integrity": "sha512-DvJbbn3dUgMxDnJLH+RZQPnXak1h4ZVYQ7CWiFWjQwBFkVajT4rfw2PdpHLTSTwxrYfnoEXkuBiwkDm6tPMQeA=="
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
|
||||
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q=="
|
||||
},
|
||||
"@reduxjs/toolkit": {
|
||||
"version": "1.3.6",
|
||||
@@ -2886,9 +2886,12 @@
|
||||
}
|
||||
},
|
||||
"@types/classnames": {
|
||||
"version": "2.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
|
||||
"integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
|
||||
"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",
|
||||
@@ -3021,9 +3024,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "17.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.3.tgz",
|
||||
"integrity": "sha512-wYOUxIgs2HZZ0ACNiIayItyluADNbONl7kt8lkLjVK8IitMH5QMyAh75Fwhmo37r1m7L2JaFj03sIfxBVDvRAg==",
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.4.tgz",
|
||||
"integrity": "sha512-onz2BqScSFMoTRdJUZUDD/7xrusM8hBA2Fktk2qgaTYPCgPvWnDEgkrOs8hhPUf2jfcIXkJ5yK6VfYormJS3Jw==",
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
@@ -5771,22 +5774,22 @@
|
||||
"dev": true
|
||||
},
|
||||
"codecov": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/codecov/-/codecov-3.7.2.tgz",
|
||||
"integrity": "sha512-fmCjAkTese29DUX3GMIi4EaKGflHa4K51EoMc29g8fBHawdk/+KEq5CWOeXLdd9+AT7o1wO4DIpp/Z1KCqCz1g==",
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/codecov/-/codecov-3.8.1.tgz",
|
||||
"integrity": "sha512-Qm7ltx1pzLPsliZY81jyaQ80dcNR4/JpcX0IHCIWrHBXgseySqbdbYfkdiXd7o/xmzQpGRVCKGYeTrHUpn6Dcw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"argv": "0.0.2",
|
||||
"ignore-walk": "3.0.3",
|
||||
"js-yaml": "3.13.1",
|
||||
"js-yaml": "3.14.0",
|
||||
"teeny-request": "6.0.1",
|
||||
"urlgrey": "0.4.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": {
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
|
||||
"integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
|
||||
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
@@ -6572,9 +6575,9 @@
|
||||
}
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.7.tgz",
|
||||
"integrity": "sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g=="
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
|
||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||
},
|
||||
"currently-unhandled": {
|
||||
"version": "0.4.1",
|
||||
@@ -7087,18 +7090,18 @@
|
||||
}
|
||||
},
|
||||
"dom-helpers": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz",
|
||||
"integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz",
|
||||
"integrity": "sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz",
|
||||
"integrity": "sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.0.0",
|
||||
@@ -7106,11 +7109,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"domhandler": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz",
|
||||
"integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.0.tgz",
|
||||
"integrity": "sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.1.0"
|
||||
"domelementtype": "^2.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7122,9 +7125,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"domelementtype": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz",
|
||||
"integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w=="
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
|
||||
"integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A=="
|
||||
},
|
||||
"domexception": {
|
||||
"version": "2.0.1",
|
||||
@@ -7152,21 +7155,21 @@
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.0.tgz",
|
||||
"integrity": "sha512-Ho16rzNMOFk2fPwChGh3D2D9OEHAfG19HgmRR2l+WLSsIstNsAYBzePH412bL0y5T44ejABIVfTHQ8nqi/tBCg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.6.0.tgz",
|
||||
"integrity": "sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA==",
|
||||
"requires": {
|
||||
"dom-serializer": "^1.0.1",
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.0.0"
|
||||
"domelementtype": "^2.2.0",
|
||||
"domhandler": "^4.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"domhandler": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz",
|
||||
"integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.0.tgz",
|
||||
"integrity": "sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.1.0"
|
||||
"domelementtype": "^2.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13886,6 +13889,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-cookie": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
||||
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -17383,9 +17391,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz",
|
||||
"integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==",
|
||||
"version": "7.13.17",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.17.tgz",
|
||||
"integrity": "sha512-NCdgJEelPTSh+FEFylhnP1ylq848l1z9t9N0j1Lfbcw0+KXGjsTvUmkxy+voLLXB5SOKMbLLx4jxYliGrYQseA==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
@@ -17754,16 +17762,16 @@
|
||||
}
|
||||
},
|
||||
"react-focus-on": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.5.1.tgz",
|
||||
"integrity": "sha512-6iE56nYNwVU6Pke362TjqRLz/G7DBGnEugkxhPAhpXEZW5og3vhc9qDPlyiHgxoiY9kYTWjdAEFz4nJgSluANg==",
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.5.2.tgz",
|
||||
"integrity": "sha512-tpPxLqw9tEuElWmcr5jqw/ULfJjdHEnom0nBW9p6y75Zsa0wOfwQNqCHqCoJcHUqSBtKXqTuYduZoSTfTOTdJw==",
|
||||
"requires": {
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-focus-lock": "^2.3.1",
|
||||
"react-remove-scroll": "^2.4.0",
|
||||
"react-style-singleton": "^2.1.0",
|
||||
"use-callback-ref": "^1.2.3",
|
||||
"use-sidecar": "^1.0.1"
|
||||
"aria-hidden": "^1.1.2",
|
||||
"react-focus-lock": "^2.5.0",
|
||||
"react-remove-scroll": "^2.4.1",
|
||||
"react-style-singleton": "^2.1.1",
|
||||
"use-callback-ref": "^1.2.5",
|
||||
"use-sidecar": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"react-helmet": {
|
||||
@@ -17800,24 +17808,34 @@
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"react-overlays": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.0.0.tgz",
|
||||
"integrity": "sha512-TKbqfAv23TFtCJ2lzISdx76p97G/DP8Rp4TOFdqM9n8GTruVYgE3jX7Zgb8+w7YJ18slTVcDTQ1/tFzdCqjVhA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.0.1.tgz",
|
||||
"integrity": "sha512-plwUJieTBbLSrgvQ4OkkbTD/deXgxiJdNuKzo6n1RWE3OVnQIU5hffCGS/nvIuu6LpXFs2majbzaXY8rcUVdWA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.1",
|
||||
"@popperjs/core": "^2.5.3",
|
||||
"@restart/hooks": "^0.3.25",
|
||||
"@babel/runtime": "^7.13.8",
|
||||
"@popperjs/core": "^2.8.6",
|
||||
"@restart/hooks": "^0.3.26",
|
||||
"@types/warning": "^3.0.0",
|
||||
"dom-helpers": "^5.2.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"uncontrollable": "^7.0.0",
|
||||
"uncontrollable": "^7.2.1",
|
||||
"warning": "^4.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.13.17",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.17.tgz",
|
||||
"integrity": "sha512-NCdgJEelPTSh+FEFylhnP1ylq848l1z9t9N0j1Lfbcw0+KXGjsTvUmkxy+voLLXB5SOKMbLLx4jxYliGrYQseA==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-popper": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.4.tgz",
|
||||
"integrity": "sha512-NacOu4zWupdQjVXq02XpTD3yFPSfg5a7fex0wa3uGKVkFK7UN6LvVxgcb+xYr56UCuWiNPMH20tntdVdJRwYew==",
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.5.tgz",
|
||||
"integrity": "sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==",
|
||||
"requires": {
|
||||
"react-fast-compare": "^3.0.1",
|
||||
"warning": "^4.0.2"
|
||||
@@ -17934,9 +17952,9 @@
|
||||
}
|
||||
},
|
||||
"react-table": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/react-table/-/react-table-7.6.3.tgz",
|
||||
"integrity": "sha512-hfPF13zDLxPMpLKzIKCE8RZud9T/XrRTsaCIf8zXpWZIZ2juCl7qrGpo3AQw9eAetXV5DP7s2GDm+hht7qq5Dw=="
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz",
|
||||
"integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "4.4.1",
|
||||
@@ -21101,14 +21119,21 @@
|
||||
"dev": true
|
||||
},
|
||||
"unbox-primitive": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.0.tgz",
|
||||
"integrity": "sha512-P/51NX+JXyxK/aigg1/ZgyccdAxm5K1+n8+tvqSntjOivPt19gvm1VC49RWYetsiub8WViUchdxl/KWHHB0kzA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
|
||||
"integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"has-bigints": "^1.0.0",
|
||||
"has-symbols": "^1.0.0",
|
||||
"which-boxed-primitive": "^1.0.1"
|
||||
"has-bigints": "^1.0.1",
|
||||
"has-symbols": "^1.0.2",
|
||||
"which-boxed-primitive": "^1.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"has-symbols": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
|
||||
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"unbzip2-stream": {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@edx/frontend-component-footer": "10.1.4",
|
||||
"@edx/frontend-enterprise": "4.2.3",
|
||||
"@edx/frontend-platform": "1.8.4",
|
||||
"@edx/paragon": "13.17.3",
|
||||
"@edx/paragon": "14.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
@@ -48,6 +48,7 @@
|
||||
"@reduxjs/toolkit": "1.3.6",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"js-cookie": "2.2.1",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.13.1",
|
||||
@@ -70,7 +71,7 @@
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.0.17",
|
||||
"axios-mock-adapter": "1.18.2",
|
||||
"codecov": "3.7.2",
|
||||
"codecov": "3.8.1",
|
||||
"es-check": "5.1.4",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
import './progressTabData.factory';
|
||||
import './upgradeCardData.factory';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
// Sample data helpful when developing & testing, to see a variety of configurations.
|
||||
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
|
||||
Factory.define('progressTabData')
|
||||
.attrs({
|
||||
end: '3027-03-31T00:00:00Z',
|
||||
certificate_data: {},
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
incomplete_count: 1,
|
||||
locked_count: 0,
|
||||
},
|
||||
course_grade: {
|
||||
letter_grade: null,
|
||||
percent: 0,
|
||||
is_passing: false,
|
||||
},
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'First subsection',
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 0,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 0.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
enrollment_mode: 'audit',
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
has_scheduled_content: false,
|
||||
studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run',
|
||||
user_has_passing_grade: false,
|
||||
verification_data: {
|
||||
link: null,
|
||||
status: 'none',
|
||||
status_date: null,
|
||||
},
|
||||
verified_mode: null,
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('upgradeCardData')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.option('accessExpiration', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('verifiedMode', ['host'], (host) => ({
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attr('timeOffsetMillis', 0);
|
||||
@@ -59,6 +59,11 @@ Object {
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
@@ -347,6 +352,11 @@ Object {
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"verifiedMode": Object {
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
@@ -432,6 +442,7 @@ Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"timeOffsetMillis": 0,
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
|
||||
@@ -131,19 +131,23 @@ export async function getProgressTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProctoringInfoData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
||||
export async function getProctoringInfoData(courseId, username) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
||||
if (username) {
|
||||
url += `&username=${encodeURIComponent(username)}`;
|
||||
}
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
@@ -156,11 +160,30 @@ export async function getProctoringInfoData(courseId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||
|
||||
let timeOffsetMillis = 0;
|
||||
if (headerDate !== undefined) {
|
||||
const headerTime = Date.parse(headerDate);
|
||||
const roundTripMillis = requestTime - responseTime;
|
||||
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
|
||||
timeOffsetMillis = headerTime - localTime;
|
||||
}
|
||||
|
||||
return timeOffsetMillis;
|
||||
}
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
let { tabData } = {};
|
||||
let requestTime = Date.now();
|
||||
let responseTime = requestTime;
|
||||
try {
|
||||
requestTime = Date.now();
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
responseTime = Date.now();
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
@@ -172,7 +195,9 @@ export async function getOutlineTabData(courseId) {
|
||||
|
||||
const {
|
||||
data,
|
||||
headers,
|
||||
} = tabData;
|
||||
|
||||
const accessExpiration = camelCaseObject(data.access_expiration);
|
||||
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
||||
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
||||
@@ -185,6 +210,7 @@ export async function getOutlineTabData(courseId) {
|
||||
const hasEnded = data.has_ended;
|
||||
const offer = camelCaseObject(data.offer);
|
||||
const resumeCourse = camelCaseObject(data.resume_course);
|
||||
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
|
||||
@@ -201,6 +227,7 @@ export async function getOutlineTabData(courseId) {
|
||||
hasEnded,
|
||||
offer,
|
||||
resumeCourse,
|
||||
timeOffsetMillis, // This should move to a global time correction reference
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
};
|
||||
|
||||
16
src/course-home/data/api.test.js
Normal file
16
src/course-home/data/api.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getTimeOffsetMillis } from './api';
|
||||
|
||||
describe('Calculate the time offset properly', () => {
|
||||
it('Should return 0 if the headerDate is not set', async () => {
|
||||
const offset = getTimeOffsetMillis(undefined, undefined, undefined);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('Should return the offset', async () => {
|
||||
const headerDate = '2021-04-13T11:01:58.135Z';
|
||||
const requestTime = new Date('2021-04-12T11:01:57.135Z');
|
||||
const responseTime = new Date('2021-04-12T11:01:58.635Z');
|
||||
const offset = getTimeOffsetMillis(headerDate, requestTime, responseTime);
|
||||
expect(offset).toBe(86398750);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ function OutlineTab({ intl }) {
|
||||
const {
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
@@ -63,6 +64,7 @@ function OutlineTab({ intl }) {
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
@@ -117,7 +119,7 @@ function OutlineTab({ intl }) {
|
||||
>
|
||||
{goalToastHeader}
|
||||
</Toast>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-between">
|
||||
<div className="row w-100 mx-0 my-3 justify-content-between">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<div role="heading" aria-level="1" className="h2">{title}</div>
|
||||
</div>
|
||||
@@ -204,6 +206,7 @@ function OutlineTab({ intl }) {
|
||||
<div className="col col-12 col-md-4">
|
||||
<ProctoringInfoPanel
|
||||
courseId={courseId}
|
||||
username={username}
|
||||
/>
|
||||
{courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<UpdateGoalSelector
|
||||
@@ -222,10 +225,14 @@ function OutlineTab({ intl }) {
|
||||
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||
: (
|
||||
<UpgradeCard
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
userTimezone={userTimezone}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
onLearnMore={
|
||||
canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null
|
||||
}
|
||||
org={org}
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
|
||||
@@ -686,7 +686,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('We are working on generating course certificates.');
|
||||
await screen.findByText('Your grade and certificate will be ready soon!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -815,7 +815,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('You are eligible to take proctored exams in this course.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your onboarding exam has been approved in another course.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -831,7 +831,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ function Section({
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
icon={faPlus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
@@ -92,6 +93,7 @@ function Section({
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
icon={faMinus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -96,7 +96,6 @@ function SequenceLink({
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
|
||||
@@ -1,51 +1,38 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, FormattedRelative } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { FormattedDate, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
function CertificateAvailableAlert({ payload }) {
|
||||
const {
|
||||
certDate,
|
||||
username,
|
||||
userTimezone,
|
||||
courseEndDate,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const certificateAvailableDateFormatted = <FormattedDate value={certDate} day="numeric" month="long" year="numeric" />;
|
||||
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<Alert type={ALERT_TYPES.SUCCESS}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.title"
|
||||
defaultMessage="We are working on generating course certificates."
|
||||
defaultMessage="Your grade and certificate will be ready soon!"
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="If you have earned a certificate, you will be able to access it {timeRemaining}. You will also be able to view your certificates on your {profileLink}."
|
||||
defaultMessage="This course ended on {courseEndDateFormatted} and final grades and certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
profileLink: (
|
||||
<Hyperlink
|
||||
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.profile"
|
||||
defaultMessage="Learner Profile"
|
||||
/>
|
||||
</Hyperlink>
|
||||
),
|
||||
timeRemaining: (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={certDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
courseEndDateFormatted,
|
||||
certificateAvailableDate: certificateAvailableDateFormatted,
|
||||
}}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
@@ -54,8 +41,7 @@ function CertificateAvailableAlert({ payload }) {
|
||||
CertificateAvailableAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
certDate: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
courseEndDate: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ function useCertificateAvailableAlert(courseId) {
|
||||
} = useModel('outline', courseId);
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
const username = authenticatedUser ? authenticatedUser.username : '';
|
||||
|
||||
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
@@ -26,6 +25,7 @@ function useCertificateAvailableAlert(courseId) {
|
||||
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
|
||||
const payload = {
|
||||
certDate: certBlock && certBlock.date,
|
||||
courseEndDate: endDate,
|
||||
username,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
@@ -37,7 +37,6 @@ function CourseEndAlert({ payload }) {
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
|
||||
@@ -42,7 +42,6 @@ function CourseStartAlert({ payload }) {
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
|
||||
@@ -166,7 +166,7 @@ const messages = defineMessages({
|
||||
},
|
||||
verifiedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.verified',
|
||||
defaultMessage: 'You can now take proctored exams in this course.',
|
||||
defaultMessage: 'Your onboarding exam has been approved in this course.',
|
||||
},
|
||||
rejectedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.rejected',
|
||||
@@ -178,7 +178,7 @@ const messages = defineMessages({
|
||||
},
|
||||
otherCourseApprovedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.otherCourseApproved',
|
||||
defaultMessage: 'You are eligible to take proctored exams in this course.',
|
||||
defaultMessage: 'Your onboarding exam has been approved in another course.',
|
||||
},
|
||||
otherCourseApprovedProctoringDetail: {
|
||||
id: 'learning.proctoringPanel.detail.otherCourseApproved',
|
||||
@@ -186,7 +186,7 @@ const messages = defineMessages({
|
||||
},
|
||||
expiringSoonProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
},
|
||||
proctoringPanelGeneralInfo: {
|
||||
id: 'learning.proctoringPanel.generalInfo',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from '@edx/paragon';
|
||||
import messages from '../messages';
|
||||
import { getProctoringInfoData } from '../../data/api';
|
||||
|
||||
function ProctoringInfoPanel({ courseId, intl }) {
|
||||
function ProctoringInfoPanel({ courseId, username, intl }) {
|
||||
const [status, setStatus] = useState('');
|
||||
const [link, setLink] = useState('');
|
||||
const [releaseDate, setReleaseDate] = useState(null);
|
||||
@@ -58,9 +58,9 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
|
||||
function getBorderClass() {
|
||||
let borderClass = '';
|
||||
if (readableStatus === readableStatuses.submitted) {
|
||||
if ([readableStatuses.submitted, readableStatuses.expiringSoon].includes(readableStatus)) {
|
||||
borderClass = 'proctoring-onboarding-submitted';
|
||||
} else if (readableStatus === readableStatuses.verified) {
|
||||
} else if ([readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus)) {
|
||||
borderClass = 'proctoring-onboarding-success';
|
||||
}
|
||||
return borderClass;
|
||||
@@ -74,7 +74,7 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getProctoringInfoData(courseId)
|
||||
getProctoringInfoData(courseId, username)
|
||||
.then(
|
||||
response => {
|
||||
if (response) {
|
||||
@@ -172,7 +172,12 @@ function ProctoringInfoPanel({ courseId, intl }) {
|
||||
|
||||
ProctoringInfoPanel.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
username: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ProctoringInfoPanel.defaultProps = {
|
||||
username: null,
|
||||
};
|
||||
|
||||
export default injectIntl(ProctoringInfoPanel);
|
||||
|
||||
@@ -1,21 +1,232 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { UpgradeButton } from '../../../generic/upgrade-button';
|
||||
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
|
||||
|
||||
function UpgradeCard({ courseId, intl, onLearnMore }) {
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
offer,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
function UpsellNoFBECardContent() {
|
||||
return (
|
||||
<ul className="fa-ul upgrade-card-ul pt-0">
|
||||
<li>
|
||||
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.verifiedCertLink"
|
||||
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
|
||||
values={{
|
||||
verifiedCertLink: (
|
||||
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>verified certificate</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.nonProfitMission"
|
||||
defaultMessage="Support our {nonProfitMission} at edX"
|
||||
values={{
|
||||
nonProfitMission: (
|
||||
<span className="font-weight-bold">non-profit mission</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function UpsellFBEFarAwayCardContent() {
|
||||
return (
|
||||
<ul className="fa-ul upgrade-card-ul">
|
||||
<li>
|
||||
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.verifiedCertLink"
|
||||
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
|
||||
values={{
|
||||
verifiedCertLink: (
|
||||
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>verified certificate</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.unlock-graded"
|
||||
defaultMessage="Unlock your access to all course activities, including {gradedAssignments}"
|
||||
values={{
|
||||
gradedAssignments: (
|
||||
<span className="font-weight-bold">graded assignments</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.fullAccess"
|
||||
defaultMessage="{fullAccess} to course content and materials, even after the course ends"
|
||||
values={{
|
||||
fullAccess: (
|
||||
<span className="font-weight-bold">Full access</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.nonProfitMission"
|
||||
defaultMessage="Support our {nonProfitMission} at edX"
|
||||
values={{
|
||||
nonProfitMission: (
|
||||
<span className="font-weight-bold">non-profit mission</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs }) {
|
||||
return (
|
||||
<div className="upgrade-card-text">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.expirationAccessLoss"
|
||||
defaultMessage="You will lose all access to this course, {includingAnyProgress}, on {date}."
|
||||
values={{
|
||||
includingAnyProgress: (<span className="font-weight-bold">including any progress</span>),
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessDate"
|
||||
day="numeric"
|
||||
month="long"
|
||||
value={new Date(accessExpirationDate)}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.expirationVerifiedCert"
|
||||
defaultMessage="Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}."
|
||||
values={{
|
||||
benefitsOfUpgrading: (<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href="https://support.edx.org/hc/en-us/articles/360013426573-What-are-the-differences-between-audit-free-and-verified-paid-courses-">benefits of upgrading</a>),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UpsellFBESoonCardContent.propTypes = {
|
||||
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
|
||||
timezoneFormatArgs: PropTypes.shape({
|
||||
timeZone: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
UpsellFBESoonCardContent.defaultProps = {
|
||||
timezoneFormatArgs: {},
|
||||
};
|
||||
|
||||
function ExpirationCountdown({ hoursToExpiration }) {
|
||||
let expirationText;
|
||||
|
||||
if (hoursToExpiration >= 24) {
|
||||
expirationText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.expiration.days"
|
||||
defaultMessage={`{dayCount, number} {dayCount, plural,
|
||||
one {day}
|
||||
other {days}} left`}
|
||||
values={{
|
||||
dayCount: (Math.floor(hoursToExpiration / 24)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (hoursToExpiration >= 1) {
|
||||
expirationText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.expiration.hours"
|
||||
defaultMessage={`{hourCount, number} {hourCount, plural,
|
||||
one {hour}
|
||||
other {hours}} left`}
|
||||
values={{
|
||||
hourCount: (hoursToExpiration),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
expirationText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.expiration.minutes"
|
||||
defaultMessage="Less than 1 hour left"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (<div className="upsell-warning">{expirationText}</div>);
|
||||
}
|
||||
|
||||
ExpirationCountdown.propTypes = {
|
||||
hoursToExpiration: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
function AccessExpirationDateBanner({ accessExpirationDate, timezoneFormatArgs }) {
|
||||
return (
|
||||
<div className="upsell-warning-light">
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.expirationr"
|
||||
defaultMessage="Course access will expire {date}"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpireDate"
|
||||
day="numeric"
|
||||
month="long"
|
||||
value={accessExpirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationDateBanner.propTypes = {
|
||||
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
|
||||
timezoneFormatArgs: PropTypes.shape({
|
||||
timeZone: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
AccessExpirationDateBanner.defaultProps = {
|
||||
timezoneFormatArgs: {},
|
||||
};
|
||||
|
||||
function UpgradeCard({
|
||||
accessExpiration,
|
||||
contentTypeGatingEnabled,
|
||||
courseId,
|
||||
offer,
|
||||
org,
|
||||
timeOffsetMillis,
|
||||
userTimezone,
|
||||
verifiedMode,
|
||||
}) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const correctedTime = new Date(Date.now() + timeOffsetMillis);
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
@@ -55,53 +266,137 @@ function UpgradeCard({ courseId, intl, onLearnMore }) {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
|
||||
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-6 col-md-12 col-lg-3 col-xl-4 p-0 text-md-center text-lg-left">
|
||||
<img
|
||||
alt={intl.formatMessage(messages.certAlt)}
|
||||
className="w-100"
|
||||
src={VerifiedCert}
|
||||
style={{ maxWidth: '10rem' }}
|
||||
/*
|
||||
There are 4 parts that change in the upgrade card:
|
||||
upgradeCardHeaderText
|
||||
expirationBanner
|
||||
upsellMessage
|
||||
offerCode
|
||||
*/
|
||||
let upgradeCardHeaderText;
|
||||
let expirationBanner;
|
||||
let upsellMessage;
|
||||
let offerCode;
|
||||
|
||||
if (!!accessExpiration && !!contentTypeGatingEnabled) {
|
||||
const accessExpirationDate = new Date(accessExpiration.expirationDate);
|
||||
const hoursToAccessExpiration = Math.floor((accessExpirationDate - correctedTime) / 1000 / 60 / 60);
|
||||
|
||||
if (offer) { // if there's a first purchase discount, message the code at the bottom
|
||||
offerCode = (
|
||||
<div className="text-center discount-info">
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.code"
|
||||
defaultMessage="Use code {code} at checkout"
|
||||
values={{
|
||||
code: (<span className="font-weight-bold">{offer.code}</span>),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-12 col-lg-9 col-xl-8 p-0 pl-lg-2 text-center mt-md-2 mt-lg-0">
|
||||
<div className="row w-100 m-0 justify-content-center">
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
{onLearnMore && (
|
||||
<div className="col-12">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="pb-0"
|
||||
onClick={onLearnMore}
|
||||
aria-labelledby="outline-sidebar-upgrade-header"
|
||||
>
|
||||
{intl.formatMessage(messages.learnMore)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hoursToAccessExpiration >= (7 * 24)) {
|
||||
if (offer) { // countdown to the first purchase discount if there is one
|
||||
const hoursToDiscountExpiration = Math.floor((new Date(offer.expirationDate) - correctedTime) / 1000 / 60 / 60);
|
||||
upgradeCardHeaderText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.firstTimeLearnerDiscount"
|
||||
defaultMessage="{percentage}% First-Time Learner Discount"
|
||||
values={{
|
||||
percentage: (offer.percentage),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToDiscountExpiration} />;
|
||||
} else {
|
||||
upgradeCardHeaderText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.accessExpiration"
|
||||
defaultMessage="Upgrade your course today"
|
||||
/>
|
||||
);
|
||||
expirationBanner = (
|
||||
<AccessExpirationDateBanner
|
||||
accessExpirationDate={accessExpirationDate}
|
||||
timezoneFormatArgs={timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
upsellMessage = <UpsellFBEFarAwayCardContent />;
|
||||
} else { // more urgent messaging if there's less than 7 days left to access expiration
|
||||
upgradeCardHeaderText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.accessExpirationUrgent"
|
||||
defaultMessage="Course Access Expiration"
|
||||
/>
|
||||
);
|
||||
expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToAccessExpiration} />;
|
||||
upsellMessage = (
|
||||
<UpsellFBESoonCardContent
|
||||
accessExpirationDate={accessExpirationDate}
|
||||
timezoneFormatArgs={timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else { // FBE is turned off
|
||||
upgradeCardHeaderText = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.upgradecard.pursueAverifiedCertificate"
|
||||
defaultMessage="Pursue a verified certificate"
|
||||
/>
|
||||
);
|
||||
upsellMessage = (<UpsellNoFBECardContent />);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-4 card upgrade-card small">
|
||||
<h2 className="h5 upgrade-card-header" id="outline-sidebar-upgrade-header">
|
||||
{upgradeCardHeaderText}
|
||||
</h2>
|
||||
{expirationBanner}
|
||||
<div className="upgrade-card-message">
|
||||
{upsellMessage}
|
||||
</div>
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
className="upgrade-card-button"
|
||||
/>
|
||||
{offerCode}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeCard.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLearnMore: PropTypes.func,
|
||||
org: PropTypes.string.isRequired,
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string,
|
||||
}),
|
||||
contentTypeGatingEnabled: PropTypes.bool,
|
||||
offer: PropTypes.shape({
|
||||
expirationDate: PropTypes.string,
|
||||
percentage: PropTypes.number,
|
||||
code: PropTypes.string,
|
||||
}),
|
||||
timeOffsetMillis: PropTypes.number,
|
||||
userTimezone: PropTypes.string,
|
||||
verifiedMode: PropTypes.shape({
|
||||
currencySymbol: PropTypes.string.isRequired,
|
||||
price: PropTypes.number.isRequired,
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
UpgradeCard.defaultProps = {
|
||||
onLearnMore: null,
|
||||
accessExpiration: null,
|
||||
contentTypeGatingEnabled: false,
|
||||
offer: null,
|
||||
timeOffsetMillis: 0,
|
||||
userTimezone: null,
|
||||
verifiedMode: null,
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeCard);
|
||||
|
||||
@@ -1,4 +1,60 @@
|
||||
.outline-sidebar-upgrade-card {
|
||||
border: 1px solid $dark-500;
|
||||
border-top: 5px solid $dark-500;
|
||||
.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;
|
||||
}
|
||||
236
src/course-home/outline-tab/widgets/UpgradeCard.test.jsx
Normal file
236
src/course-home/outline-tab/widgets/UpgradeCard.test.jsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp, render, screen } from '../../../setupTest';
|
||||
import UpgradeCard from './UpgradeCard';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
const dateNow = new Date('2021-04-13T11:01:58.000Z');
|
||||
jest
|
||||
.spyOn(global.Date, 'now')
|
||||
.mockImplementation(() => dateNow.valueOf());
|
||||
|
||||
describe('Upgrade Card', () => {
|
||||
function buildAndRender(attributes) {
|
||||
const upgradeCardData = Factory.build('upgradeCardData', { ...attributes });
|
||||
render(<UpgradeCard {...upgradeCardData} />);
|
||||
}
|
||||
|
||||
it('does not render when there is no verified mode', async () => {
|
||||
buildAndRender({ verifiedMode: null });
|
||||
expect(screen.queryByRole('link', { name: 'Upgrade for $149' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders non-FBE when there is a verified mode but no FBE', async () => {
|
||||
buildAndRender();
|
||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders non-FBE when there is a verified mode and access expiration, but no content gating', async () => {
|
||||
const expirationDate = new Date(dateNow);
|
||||
expirationDate.setMinutes(expirationDate.getMinutes() + 45);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: expirationDate.toString(),
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders non-FBE when there is a verified mode and content gating, but no access expiration', async () => {
|
||||
buildAndRender({
|
||||
contentTypeGatingEnabled: true,
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FBE expiration within an hour properly', async () => {
|
||||
const expirationDate = new Date(dateNow);
|
||||
expirationDate.setMinutes(expirationDate.getMinutes() + 45);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: expirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Less than 1 hour left')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 13.');
|
||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FBE expiration within 24 hours properly', async () => {
|
||||
const expirationDate = new Date(dateNow);
|
||||
expirationDate.setHours(expirationDate.getHours() + 12);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: expirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
||||
expect(screen.getByText('12 hours left')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 13.');
|
||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FBE expiration within 7 days properly', async () => {
|
||||
const expirationDate = new Date(dateNow);
|
||||
expirationDate.setDate(expirationDate.getDate() + 6);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: expirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
||||
expect(screen.getByText('6 days left')).toBeInTheDocument(); // setting the time to 12 will mean that it's slightly less than 12
|
||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 19.');
|
||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FBE expiration greater than 7 days properly', async () => {
|
||||
const expirationDate = new Date(dateNow);
|
||||
expirationDate.setDate(expirationDate.getDate() + 14);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: expirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Upgrade your course today' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Course access will expire/s).textContent).toMatch('Course access will expire April 27');
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders discount less than an hour properly', async () => {
|
||||
const accessExpirationDate = new Date(dateNow);
|
||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 21);
|
||||
const discountExpirationDate = new Date(dateNow);
|
||||
discountExpirationDate.setMinutes(discountExpirationDate.getMinutes() + 30);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: accessExpirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
offer: {
|
||||
expirationDate: discountExpirationDate.toString(),
|
||||
percentage: 15,
|
||||
code: 'Welcome15',
|
||||
discountedPrice: '126.65',
|
||||
originalPrice: '149',
|
||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Less than 1 hour left')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
||||
});
|
||||
|
||||
it('renders discount less than a day properly', async () => {
|
||||
const accessExpirationDate = new Date(dateNow);
|
||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 21);
|
||||
const discountExpirationDate = new Date(dateNow);
|
||||
discountExpirationDate.setHours(discountExpirationDate.getHours() + 12);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: accessExpirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
offer: {
|
||||
expirationDate: discountExpirationDate.toString(),
|
||||
percentage: 15,
|
||||
code: 'Welcome15',
|
||||
discountedPrice: '126.65',
|
||||
originalPrice: '149',
|
||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/hours left/s).textContent).toMatch('12 hours left');
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
||||
});
|
||||
|
||||
it('renders discount less a week properly', async () => {
|
||||
const accessExpirationDate = new Date(dateNow);
|
||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 21);
|
||||
const discountExpirationDate = new Date(dateNow);
|
||||
discountExpirationDate.setDate(discountExpirationDate.getDate() + 6);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: accessExpirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
offer: {
|
||||
expirationDate: discountExpirationDate.toString(),
|
||||
percentage: 15,
|
||||
code: 'Welcome15',
|
||||
discountedPrice: '126.65',
|
||||
originalPrice: '149',
|
||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/days left/s).textContent).toMatch('6 days left');
|
||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
|
||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
|
||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
||||
});
|
||||
|
||||
it('renders discount less a week access expiration less than a week properly', async () => {
|
||||
const accessExpirationDate = new Date(dateNow);
|
||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 5);
|
||||
const discountExpirationDate = new Date(dateNow);
|
||||
discountExpirationDate.setDate(discountExpirationDate.getDate() + 6);
|
||||
buildAndRender({
|
||||
accessExpiration: {
|
||||
expirationDate: accessExpirationDate.toString(),
|
||||
},
|
||||
contentTypeGatingEnabled: true,
|
||||
offer: {
|
||||
expirationDate: discountExpirationDate.toString(),
|
||||
percentage: 15,
|
||||
code: 'Welcome15',
|
||||
discountedPrice: '126.65',
|
||||
originalPrice: '149',
|
||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
||||
expect(screen.getByText('5 days left')).toBeInTheDocument(); // setting the time to 12 will mean that it's slightly less than 12
|
||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 18.');
|
||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function ProgressHeader({ intl }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row w-100 m-0 mb-4 justify-content-between">
|
||||
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
||||
<h1>{intl.formatMessage(messages.progressHeader)}</h1>
|
||||
{administrator && studioUrl && (
|
||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import CertificateStatus from './certificate-status/CertificateStatus';
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -8,24 +10,50 @@ import GradeSummary from './grades/grade-summary/GradeSummary';
|
||||
import ProgressHeader from './ProgressHeader';
|
||||
import RelatedLinks from './related-links/RelatedLinks';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function ProgressTab() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
const isLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
desktop: 992,
|
||||
});
|
||||
|
||||
const OnMobile = layout.is('mobile');
|
||||
const OnDesktop = layout.isAtLeast('desktop');
|
||||
return (
|
||||
<>
|
||||
<ProgressHeader />
|
||||
<div className="row w-100 m-0">
|
||||
{/* Main body */}
|
||||
<div className="col-12 col-lg-8 p-0">
|
||||
<div className="col-12 col-md-8 p-0">
|
||||
<CourseCompletion />
|
||||
<OnMobile>
|
||||
<CertificateStatus />
|
||||
</OnMobile>
|
||||
<CourseGrade />
|
||||
<div className="my-4 p-4 rounded shadow-sm">
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={isLocked}>
|
||||
<GradeSummary />
|
||||
<DetailedGrades />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<div className="col-12 col-lg-4 p-0 px-lg-4">
|
||||
<CertificateStatus />
|
||||
<div className="col-12 col-md-4 p-0 px-md-4">
|
||||
<OnDesktop>
|
||||
<CertificateStatus />
|
||||
</OnDesktop>
|
||||
<RelatedLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
362
src/course-home/progress-tab/ProgressTab.test.jsx
Normal file
362
src/course-home/progress-tab/ProgressTab.test.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, act,
|
||||
} from '../../setupTest';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
import * as thunks from '../data/thunks';
|
||||
import initializeStore from '../../store';
|
||||
import ProgressTab from './ProgressTab';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Progress Tab', () => {
|
||||
let axiosMock;
|
||||
|
||||
const courseId = 'course-v1:edX+Test+run';
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('progressTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
function setTabData(attributes, options) {
|
||||
const progressTabData = Factory.build('progressTabData', attributes, options);
|
||||
axiosMock.onGet(progressUrl).reply(200, progressTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
await act(async () => render(<ProgressTab />, { store }));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
// Set defaults for network requests
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
describe('Course Grade', () => {
|
||||
it('renders Course Grade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grades')).toBeInTheDocument();
|
||||
expect(screen.getByText('This represents your weighted grade against the grade needed to pass this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy for non-passing', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy for passing with pass/fail grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
is_passing: true,
|
||||
letter_grade: 'Pass',
|
||||
percent: 0.9,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('You’re currently passing this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy and tooltip for non-passing with letter grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
is_passing: false,
|
||||
letter_grade: null,
|
||||
percent: 0,
|
||||
},
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct copy and tooltip for passing with letter grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
is_passing: true,
|
||||
letter_grade: 'B',
|
||||
percent: 0.85,
|
||||
},
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(await screen.findByText('You’re currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tooltip for grade range', async () => {
|
||||
setTabData({
|
||||
course_grade: {
|
||||
percent: 0,
|
||||
is_passing: false,
|
||||
},
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
A: 0.9,
|
||||
B: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
const tooltip = await screen.getByRole('button', { name: 'Grade range tooltip' });
|
||||
fireEvent.click(tooltip);
|
||||
expect(screen.getByText('Grade ranges for this course:'));
|
||||
expect(screen.getByText('A: 90%-100%'));
|
||||
expect(screen.getByText('B: 80%-90%'));
|
||||
expect(screen.getByText('F: <80%'));
|
||||
});
|
||||
|
||||
it('renders locked feature preview when user has locked content', async () => {
|
||||
setTabData({
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
incomplete_count: 1,
|
||||
locked_count: 1,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('locked feature')).toBeInTheDocument();
|
||||
});
|
||||
it('does not render locked feature preview when user does not have locked content', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('locked feature')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grade Summary', () => {
|
||||
it('renders Grade Summary table when assignment policies are populated', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Grade Summary when assignment policies are not populated', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [],
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailed Grades', () => {
|
||||
it('renders Detailed Grades table when section scores are populated', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('link', { name: 'First subsection' }));
|
||||
expect(screen.getByRole('link', { name: 'Second subsection' }));
|
||||
});
|
||||
|
||||
it('render message when section scores are not populated', async () => {
|
||||
setTabData({
|
||||
section_scores: [],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate Status', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => {
|
||||
const matches = !!(query === 'screen and (min-width: 992px)');
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrolled user', () => {
|
||||
beforeEach(async () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
});
|
||||
|
||||
it('Displays text for nonPassing case when learner does not have a passing grade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('In order to qualify for a certificate, you must have a passing grade.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays text for inProgress case when more content is scheduled and the learner does not have a passing grade', async () => {
|
||||
setTabData({
|
||||
has_scheduled_content: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays request certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'requesting' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Request certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays verify identity link', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
user_has_passing_grade: true,
|
||||
verification_data: { link: 'test' },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Verify ID' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays verification pending message', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verification_data: { status: 'pending' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Verify ID' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays download link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays webview link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays certificate is earned but unavailable message', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'earned_but_not_available' },
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Certificate status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays upgrade link when available', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
verified_mode: {
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
// Keep these text checks in sync with "audit only" test below, so it doesn't end up checking for text that is
|
||||
// never actually there, when/if the text changes.
|
||||
expect(screen.getByText('You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays nothing if audit only', async () => {
|
||||
setTabData({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
});
|
||||
await fetchAndRender();
|
||||
// Keep these queries in sync with "upgrade link" test above, so we don't end up checking for text that is
|
||||
// never actually there, when/if the text changes.
|
||||
expect(screen.queryByText('You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Upgrade now' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not display the certificate component if it does not match any statuses', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'bogus_status',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
setMetadata({ is_enrolled: true });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Does not display the certificate component if the user is not enrolled', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,175 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
FormattedDate, FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Card } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
|
||||
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
|
||||
import { requestCert } from '../../data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
function CertificateStatus({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
userHasPassingGrade,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
end,
|
||||
verificationData,
|
||||
certificateData: {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
downloadUrl,
|
||||
},
|
||||
verifiedMode,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
let certCase;
|
||||
let body;
|
||||
let buttonAction;
|
||||
let buttonLocation;
|
||||
let buttonText;
|
||||
let endDate;
|
||||
|
||||
const dashboardLink = <DashboardLink />;
|
||||
const idVerificationSupportLink = <IdVerificationSupportLink />;
|
||||
const profileLink = <ProfileLink />;
|
||||
|
||||
if (mode === COURSE_EXIT_MODES.nonPassing) {
|
||||
certCase = 'notPassing';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
} else if (mode === COURSE_EXIT_MODES.inProgress) {
|
||||
certCase = 'inProgress';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||
switch (certStatus) {
|
||||
case 'requesting':
|
||||
// Requestable
|
||||
certCase = 'requestable';
|
||||
buttonAction = () => { dispatch(requestCert(courseId)); };
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
|
||||
break;
|
||||
|
||||
case 'unverified':
|
||||
certCase = 'unverified';
|
||||
if (verificationData.status === 'pending') {
|
||||
body = (<p>{intl.formatMessage(messages.unverifiedPendingBody)}</p>);
|
||||
} else {
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="progress.certificateStatus.unverifiedBody"
|
||||
defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}."
|
||||
values={{ idVerificationSupportLink }}
|
||||
/>
|
||||
);
|
||||
buttonLocation = verificationData.link;
|
||||
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'downloadable':
|
||||
// Certificate available, download/viewable
|
||||
certCase = 'downloadable';
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="progress.certificateStatus.downloadableBody"
|
||||
defaultMessage="
|
||||
Showcase your accomplishment on LinkedIn or your resume today.
|
||||
You can download your certificate now and access it any time from your
|
||||
{dashboardLink} and {profileLink}."
|
||||
values={{ dashboardLink, profileLink }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (certWebViewUrl) {
|
||||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
buttonText = intl.formatMessage(messages.viewableButton);
|
||||
} else if (downloadUrl) {
|
||||
buttonLocation = downloadUrl;
|
||||
buttonText = intl.formatMessage(messages.downloadableButton);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'earned_but_not_available':
|
||||
certCase = 'notAvailable';
|
||||
endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'audit_passing':
|
||||
case 'honor_passing':
|
||||
if (verifiedMode) {
|
||||
certCase = 'upgrade';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
buttonLocation = verifiedMode.upgradeUrl;
|
||||
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
|
||||
}
|
||||
break;
|
||||
|
||||
// This code shouldn't be hit but coding defensively since switch expects a default statement
|
||||
default:
|
||||
certCase = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!certCase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = intl.formatMessage(messages[`${certCase}Header`]);
|
||||
|
||||
function CertificateStatus() {
|
||||
return (
|
||||
<section className="text-dark-700 rounded shadow-sm mb-4 p-4">
|
||||
{/* TODO: AA-719 */}
|
||||
<h3 className="h4">Certificate status</h3>
|
||||
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
|
||||
<Card className="bg-light-200 shadow-sm border-0">
|
||||
<Card.Body>
|
||||
<Card.Title>
|
||||
<h3>{header}</h3>
|
||||
</Card.Title>
|
||||
<Card.Text className="small text-gray-700">
|
||||
{body}
|
||||
</Card.Text>
|
||||
{buttonText && (buttonLocation || buttonAction) && (
|
||||
<Button variant="outline-brand" onClick={buttonAction} href={buttonLocation} block>{buttonText}</Button>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CertificateStatus;
|
||||
CertificateStatus.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateStatus);
|
||||
|
||||
82
src/course-home/progress-tab/certificate-status/messages.js
Normal file
82
src/course-home/progress-tab/certificate-status/messages.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
notPassingHeader: {
|
||||
id: 'progress.certificateStatus.notPassingHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
notPassingBody: {
|
||||
id: 'progress.certificateStatus.notPassingBody',
|
||||
defaultMessage: 'In order to qualify for a certificate, you must have a passing grade.',
|
||||
},
|
||||
inProgressHeader: {
|
||||
id: 'progress.certificateStatus.inProgressHeader',
|
||||
defaultMessage: 'More content is coming soon!',
|
||||
},
|
||||
inProgressBody: {
|
||||
id: 'progress.certificateStatus.inProgressBody',
|
||||
defaultMessage: 'It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.',
|
||||
},
|
||||
requestableHeader: {
|
||||
id: 'progress.certificateStatus.requestableHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
requestableBody: {
|
||||
id: 'progress.certificateStatus.requestableBody',
|
||||
defaultMessage: 'Congratulations, you qualified for a certificate! In order to access your certificate, request it below.',
|
||||
},
|
||||
requestableButton: {
|
||||
id: 'progress.certificateStatus.requestableButton',
|
||||
defaultMessage: 'Request certificate',
|
||||
},
|
||||
unverifiedHeader: {
|
||||
id: 'progress.certificateStatus.unverifiedHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
unverifiedButton: {
|
||||
id: 'progress.certificateStatus.unverifiedButton',
|
||||
defaultMessage: 'Verify ID',
|
||||
},
|
||||
unverifiedPendingBody: {
|
||||
id: 'progress.certificateStatus.courseCelebration.verificationPending',
|
||||
defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.',
|
||||
},
|
||||
downloadableHeader: {
|
||||
id: 'progress.certificateStatus.downloadableHeader',
|
||||
defaultMessage: 'Your certificate is available!',
|
||||
},
|
||||
downloadableBody: {
|
||||
id: 'progress.certificateStatus.downloadableBody',
|
||||
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.',
|
||||
},
|
||||
downloadableButton: {
|
||||
id: 'progress.certificateStatus.downloadableButton',
|
||||
defaultMessage: 'Download my certificate',
|
||||
},
|
||||
viewableButton: {
|
||||
id: 'progress.certificateStatus.viewableButton',
|
||||
defaultMessage: 'View my certificate',
|
||||
},
|
||||
notAvailableHeader: {
|
||||
id: 'progress.certificateStatus.notAvailableHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
},
|
||||
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',
|
||||
},
|
||||
upgradeBody: {
|
||||
id: 'progress.certificateStatus.upgradeBody',
|
||||
defaultMessage: 'You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.',
|
||||
},
|
||||
upgradeButton: {
|
||||
id: 'progress.certificateStatus.upgradeButton',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||
const [showCompletePopover, setShowCompletePopover] = useState(false);
|
||||
|
||||
const completeSegmentOffset = (3.6 * completePercentage) / 8;
|
||||
let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
|
||||
|
||||
const lockedSegmentOffset = lockedPercentage - 75;
|
||||
if (lockedPercentage > 0) {
|
||||
completeTooltipDegree = (lockedSegmentOffset + completePercentage) * -3.6 + 90 + completeSegmentOffset;
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
className="donut-segment-group"
|
||||
onBlur={() => setShowCompletePopover(false)}
|
||||
onFocus={() => setShowCompletePopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-segment complete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
|
||||
strokeDashoffset={lockedSegmentOffset + completePercentage}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showCompletePopover}
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover aria-hidden="true">
|
||||
<Popover.Content>
|
||||
{intl.formatMessage(messages.completeContentTooltip)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
{/* Used to anchor the tooltip within the complete segment's stroke */}
|
||||
<rect x="19" y="3" style={{ transform: `rotate(${completeTooltipDegree}deg)` }} />
|
||||
</OverlayTrigger>
|
||||
|
||||
{/* Segment dividers */}
|
||||
{lockedPercentage > 0 && lockedPercentage < 100 && (
|
||||
<circle
|
||||
className="donut-segment divider-stroke"
|
||||
strokeDasharray="0.3 99.7"
|
||||
strokeDashoffset={0.15 + lockedSegmentOffset}
|
||||
/>
|
||||
)}
|
||||
{completePercentage < 100 && lockedPercentage < 100 && lockedPercentage + completePercentage === 100 && (
|
||||
<circle
|
||||
className="donut-segment divider-stroke"
|
||||
strokeDasharray="0.3 99.7"
|
||||
strokeDashoffset="25.15"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
CompleteDonutSegment.propTypes = {
|
||||
completePercentage: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
lockedPercentage: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CompleteDonutSegment);
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import CompleteDonutSegment from './CompleteDonutSegment';
|
||||
import IncompleteDonutSegment from './IncompleteDonutSegment';
|
||||
import LockedDonutSegment from './LockedDonutSegment';
|
||||
import messages from './messages';
|
||||
|
||||
function CompletionDonutChart({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
completeCount,
|
||||
incompleteCount,
|
||||
lockedCount,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const numTotalUnits = completeCount + incompleteCount + lockedCount;
|
||||
const completePercentage = Number(((completeCount / numTotalUnits) * 100).toFixed(0));
|
||||
const lockedPercentage = Number(((lockedCount / numTotalUnits) * 100).toFixed(0));
|
||||
const incompletePercentage = 100 - completePercentage - lockedPercentage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
|
||||
{/* The radius (or "r" attribute) is based off of a circumference of 100 in order to simplify percentage
|
||||
calculations. The subsequent stroke-dasharray values found in each segment should add up to equal 100
|
||||
in order to wrap around the circle once. */}
|
||||
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
|
||||
<g className="donut-chart-text">
|
||||
<text x="50%" y="50%" className="donut-chart-number">
|
||||
{completePercentage}%
|
||||
</text>
|
||||
<text x="50%" y="50%" className="donut-chart-label">
|
||||
{intl.formatMessage(messages.donutLabel)}
|
||||
</text>
|
||||
</g>
|
||||
<IncompleteDonutSegment incompletePercentage={incompletePercentage} />
|
||||
<LockedDonutSegment lockedPercentage={lockedPercentage} />
|
||||
<CompleteDonutSegment completePercentage={completePercentage} lockedPercentage={lockedPercentage} />
|
||||
</svg>
|
||||
<div className="sr-only">
|
||||
{intl.formatMessage(messages.percentComplete, { percent: completePercentage })}
|
||||
{intl.formatMessage(messages.percentIncomplete, { percent: incompletePercentage })}
|
||||
{lockedPercentage > 0 && (
|
||||
<>
|
||||
{intl.formatMessage(messages.percentLocked, { percent: lockedPercentage })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CompletionDonutChart.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CompletionDonutChart);
|
||||
@@ -0,0 +1,74 @@
|
||||
.donut rect {
|
||||
fill: transparent;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.donut-chart-label {
|
||||
font: {
|
||||
family: $font-family-sans-serif;
|
||||
size: .2rem;
|
||||
weight: $font-weight-normal;
|
||||
}
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.donut-chart-number {
|
||||
font: {
|
||||
family: $font-family-monospace;
|
||||
size: .5rem;
|
||||
weight: $font-weight-bold;
|
||||
}
|
||||
line-height: 1rem;
|
||||
text-anchor: middle;
|
||||
-moz-transform: translateY(-0.6em);
|
||||
-ms-transform: translateY(-0.6em);
|
||||
-webkit-transform: translateY(-0.6em);
|
||||
transform: translateY(-0.6em);
|
||||
}
|
||||
|
||||
.donut-chart-text {
|
||||
fill: $primary-500;
|
||||
-moz-transform: translateY(0.25em);
|
||||
-ms-transform: translateY(0.25em);
|
||||
-webkit-transform: translateY(0.25em);
|
||||
transform: translateY(0.25em);
|
||||
}
|
||||
|
||||
.donut-ring, .donut-segment {
|
||||
stroke-width: 6px;
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.donut-segment-group {
|
||||
cursor: pointer;
|
||||
pointer-events: visibleStroke;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
circle {
|
||||
stroke-width: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.donut-ring, .donut-segment, .donut-hole {
|
||||
&.complete-stroke {
|
||||
stroke: $info-500;
|
||||
}
|
||||
|
||||
&.divider-stroke {
|
||||
stroke-width: 7px;
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
&.incomplete-stroke {
|
||||
stroke: $light-300;
|
||||
}
|
||||
|
||||
&.locked-stroke {
|
||||
stroke: $primary-500;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
function CourseCompletion() {
|
||||
// TODO: AA-720
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
completeCount,
|
||||
incompleteCount,
|
||||
lockedCount,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const total = completeCount + incompleteCount + lockedCount;
|
||||
const completePercentage = ((completeCount / total) * 100).toFixed(0);
|
||||
const incompletePercentage = ((incompleteCount / total) * 100).toFixed(0);
|
||||
const lockedPercentage = ((lockedCount / total) * 100).toFixed(0);
|
||||
import CompletionDonutChart from './CompletionDonutChart';
|
||||
import messages from './messages';
|
||||
|
||||
function CourseCompletion({ intl }) {
|
||||
return (
|
||||
<section className="text-dark-700 mb-4 rounded shadow-sm p-4">
|
||||
<h2>Course completion</h2>
|
||||
<p className="small">This represents how much course content you have completed.</p>
|
||||
Complete: {completePercentage}%
|
||||
Incomplete: {incompletePercentage}%
|
||||
Locked: {lockedPercentage}%
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 col-sm-6 col-md-7 p-0">
|
||||
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.completionBody)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
|
||||
<CompletionDonutChart />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CourseCompletion;
|
||||
CourseCompletion.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseCompletion);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function IncompleteDonutSegment({ incompletePercentage, intl }) {
|
||||
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
|
||||
|
||||
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
|
||||
const incompleteTooltipDegree = incompletePercentage < 100 ? incompleteSegmentOffset : 0;
|
||||
|
||||
return (
|
||||
<g
|
||||
className="donut-segment-group"
|
||||
onBlur={() => setShowIncompletePopover(false)}
|
||||
onFocus={() => setShowIncompletePopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-ring incomplete-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${incompletePercentage} ${100 - incompletePercentage}`}
|
||||
strokeDashoffset="25"
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showIncompletePopover}
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover aria-hidden="true">
|
||||
<Popover.Content>
|
||||
{intl.formatMessage(messages.incompleteContentTooltip)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
{/* Used to anchor the tooltip within the incomplete segment's stroke */}
|
||||
<rect x="19" y="3" style={{ transform: `rotate(${incompleteTooltipDegree}deg)` }} />
|
||||
</OverlayTrigger>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
IncompleteDonutSegment.propTypes = {
|
||||
incompletePercentage: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(IncompleteDonutSegment);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function LockedDonutSegment({ intl, lockedPercentage }) {
|
||||
const [showLockedPopover, setShowLockedPopover] = useState(false);
|
||||
|
||||
if (!lockedPercentage > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconDegree = lockedPercentage > 8 ? (3.6 * lockedPercentage) / 8 : ((3.6 * lockedPercentage) / 5) * 2;
|
||||
|
||||
return (
|
||||
<g
|
||||
className="donut-segment-group"
|
||||
onBlur={() => setShowLockedPopover(false)}
|
||||
onFocus={() => setShowLockedPopover(true)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<circle
|
||||
className="donut-segment locked-stroke"
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.91549430918954"
|
||||
strokeDasharray={`${lockedPercentage} ${100 - lockedPercentage}`}
|
||||
strokeDashoffset={lockedPercentage - 75}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<OverlayTrigger
|
||||
show={showLockedPopover}
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover aria-hidden="true">
|
||||
<Popover.Content>
|
||||
{intl.formatMessage(messages.lockedContentTooltip)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<g
|
||||
width="6"
|
||||
height="21"
|
||||
viewBox="0 0 21 6"
|
||||
style={{
|
||||
transformOrigin: 'center',
|
||||
transform: `rotate(-${iconDegree}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Locked icon */}
|
||||
<path
|
||||
d="M20 8.00002H17V6.21002C17 3.60002 15.09 1.27002 12.49 1.02002C9.51 0.740018 7 3.08002 7 6.00002V8.00002H4V22H20V8.00002ZM12 17C10.9 17 10 16.1 10 15C10 13.9 10.9 13 12 13C13.1 13 14 13.9 14 15C14 16.1 13.1 17 12 17ZM9 8.00002V6.00002C9 4.34002 10.34 3.00002 12 3.00002C13.66 3.00002 15 4.34002 15 6.00002V8.00002H9Z"
|
||||
fill={lockedPercentage > 5 ? 'white' : 'transparent'}
|
||||
style={{ transform: `scale(0.18) translate(5.8em, .7em) rotate(${iconDegree}deg)` }}
|
||||
/>
|
||||
</g>
|
||||
</OverlayTrigger>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
LockedDonutSegment.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
lockedPercentage: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LockedDonutSegment);
|
||||
42
src/course-home/progress-tab/course-completion/messages.js
Normal file
42
src/course-home/progress-tab/course-completion/messages.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
donutLabel: {
|
||||
id: 'progress.completion.donut.label',
|
||||
defaultMessage: 'completed',
|
||||
},
|
||||
completionBody: {
|
||||
id: 'progress.completion.body',
|
||||
defaultMessage: 'This represents how much of the course content you have completed. Note that some content may not yet be released.',
|
||||
},
|
||||
completeContentTooltip: {
|
||||
id: 'progress.completion.tooltip.locked',
|
||||
defaultMessage: 'Content that you have completed.',
|
||||
},
|
||||
courseCompletion: {
|
||||
id: 'progress.completion.header',
|
||||
defaultMessage: 'Course completion',
|
||||
},
|
||||
incompleteContentTooltip: {
|
||||
id: 'progress.completion.tooltip',
|
||||
defaultMessage: 'Content that you have access to and have not completed.',
|
||||
},
|
||||
lockedContentTooltip: {
|
||||
id: 'progress.completion.tooltip.complete',
|
||||
defaultMessage: 'Content that is locked and available only to those who upgrade.',
|
||||
},
|
||||
percentComplete: {
|
||||
id: 'progress.completion.donut.percentComplete',
|
||||
defaultMessage: 'You have completed {percent}% of content in this course.',
|
||||
},
|
||||
percentIncomplete: {
|
||||
id: 'progress.completion.donut.percentIncomplete',
|
||||
defaultMessage: 'You have not completed {percent}% of content in this course that you have access to.',
|
||||
},
|
||||
percentLocked: {
|
||||
id: 'progress.completion.donut.percentLocked',
|
||||
defaultMessage: '{percent}% of content in this course is locked and available only for those who upgrade.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,13 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import CourseGradeFooter from './CourseGradeFooter';
|
||||
import CourseGradeHeader from './CourseGradeHeader';
|
||||
import GradeBar from './GradeBar';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function CourseGrade({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
let passingGrade;
|
||||
if (gradeRange.pass) {
|
||||
passingGrade = gradeRange.pass * 100;
|
||||
} else {
|
||||
passingGrade = Object.entries(gradeRange).pop()[1] * 100;
|
||||
}
|
||||
|
||||
passingGrade = Number(passingGrade.toFixed(0));
|
||||
|
||||
const isLocked = lockedCount > 0;
|
||||
const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
|
||||
|
||||
function CourseGrade() {
|
||||
return (
|
||||
<section className="text-dark-700 my-4 rounded shadow-sm p-4">
|
||||
{/* TODO: AA-721 */}
|
||||
<h2>Grades</h2>
|
||||
<p className="small">This represents your weighted grade against the grade needed to pass this course.</p>
|
||||
<section className="text-dark-700 my-4 rounded shadow-sm">
|
||||
{isLocked && <CourseGradeHeader />}
|
||||
<div className={applyLockedOverlay}>
|
||||
<div className="row w-100 m-0 p-4">
|
||||
<div className="col-12 col-sm-6 p-0 pr-sm-2">
|
||||
<h2>{intl.formatMessage(messages.grades)}</h2>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.courseGradeBody)}
|
||||
</p>
|
||||
</div>
|
||||
<GradeBar passingGrade={passingGrade} />
|
||||
</div>
|
||||
<CourseGradeFooter passingGrade={passingGrade} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CourseGrade;
|
||||
CourseGrade.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGrade);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { layoutGenerator } from 'react-break';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { CheckCircle, WarningFilled } from '@edx/paragon/icons';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import GradeRangeTooltip from './GradeRangeTooltip';
|
||||
import messages from '../messages';
|
||||
|
||||
function CourseGradeFooter({ intl, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
letterGrade,
|
||||
},
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
tablet: 768,
|
||||
});
|
||||
|
||||
const OnMobile = layout.is('mobile');
|
||||
const OnAtLeastTablet = layout.isAtLeast('tablet');
|
||||
|
||||
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
|
||||
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
|
||||
|
||||
if (isPassing) {
|
||||
if (hasLetterGrades) {
|
||||
const letterGrades = Object.keys(gradeRange);
|
||||
const gradeIndex = letterGrades.indexOf(letterGrade);
|
||||
const minGrade = gradeRange[letterGrade] * 100;
|
||||
const maxGrade = gradeIndex > 0 ? gradeRange[letterGrades[gradeIndex - 1]] * 100 : 100;
|
||||
|
||||
footerText = intl.formatMessage(messages.courseGradeFooterPassingWithGrade, {
|
||||
letterGrade,
|
||||
minGrade: minGrade.toFixed(0),
|
||||
maxGrade: maxGrade.toFixed(0),
|
||||
});
|
||||
} else {
|
||||
footerText = intl.formatMessage(messages.courseGradeFooterGenericPassing);
|
||||
}
|
||||
}
|
||||
|
||||
const icon = isPassing ? <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
|
||||
: <Icon src={WarningFilled} className="d-inline-flex align-bottom" />;
|
||||
|
||||
return (
|
||||
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${isPassing ? 'bg-success-100' : 'bg-warning-100'}`}>
|
||||
<div className="col-auto p-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="col-11 pl-2 px-0">
|
||||
<OnMobile>
|
||||
<span className="h5 align-bottom">
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</OnMobile>
|
||||
<OnAtLeastTablet>
|
||||
<span className="h4 m-0 align-bottom">
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</OnAtLeastTablet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGradeFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGradeFooter);
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import messages from '../messages';
|
||||
|
||||
function CourseGradeHeader({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
verifiedMode,
|
||||
} = useModel('progress', courseId);
|
||||
return (
|
||||
<div className="row w-100 m-0 p-4 rounded-top bg-primary-500 text-white">
|
||||
<div className="col-12 col-md-9 p-0">
|
||||
<div className="row w-100 m-0 p-0">
|
||||
<div className="col-1 p-0">
|
||||
<Icon src={Locked} />
|
||||
</div>
|
||||
<div className="col-11 px-2 p-sm-0 h4 text-white">
|
||||
<span aria-hidden="true">
|
||||
{intl.formatMessage(messages.courseGradePreviewHeaderAriaHidden)}
|
||||
</span>
|
||||
{intl.formatMessage(messages.courseGradePreviewHeader)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row w-100 m-0 p-0 justify-content-end">
|
||||
<div className="col-11 px-2 p-sm-0 small">
|
||||
{intl.formatMessage(messages.courseGradePreviewBody)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-3 mt-3 mt-md-0 p-0 align-self-center text-right">
|
||||
{verifiedMode && (
|
||||
<Button variant="brand" size="sm" href={verifiedMode.upgradeUrl}>
|
||||
{intl.formatMessage(messages.courseGradePreviewUpgradeButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGradeHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGradeHeader);
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function CurrentGradeTooltip({ intl, tooltipClassName }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
percent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = percent * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
show
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
|
||||
<Popover.Content className={isPassing ? 'text-white' : 'text-dark-700'}>
|
||||
{currentGrade.toFixed(0)}%
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<g>
|
||||
<circle cx={`${currentGrade}%`} cy="50%" r="8.5" fill="transparent" />
|
||||
<rect className="grade-bar__divider" x={`${currentGrade}%`} style={{ transform: 'translateY(2.61em)' }} />
|
||||
</g>
|
||||
</OverlayTrigger>
|
||||
<text
|
||||
className="x-small"
|
||||
textAnchor={currentGrade < 50 ? 'start' : 'end'}
|
||||
x={`${currentGrade}%`}
|
||||
y="20px"
|
||||
style={{ transform: `translateX(${currentGrade < 50 ? '' : '-'}3.4em)` }}
|
||||
>
|
||||
{intl.formatMessage(messages.currentGradeLabel)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CurrentGradeTooltip.defaultProps = {
|
||||
tooltipClassName: '',
|
||||
};
|
||||
|
||||
CurrentGradeTooltip.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
tooltipClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(CurrentGradeTooltip);
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import CurrentGradeTooltip from './CurrentGradeTooltip';
|
||||
import PassingGradeTooltip from './PassingGradeTooltip';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function GradeBar({ intl, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
completionSummary: {
|
||||
lockedCount,
|
||||
},
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
percent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = percent * 100;
|
||||
|
||||
const isLocked = lockedCount > 0;
|
||||
const lockedTooltipClassName = isLocked ? 'locked-overlay' : '';
|
||||
|
||||
return (
|
||||
<div className="col-12 col-sm-6 align-self-center">
|
||||
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
|
||||
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
|
||||
<g style={{ transform: 'translateY(2.61em)' }}>
|
||||
<rect className="grade-bar__base" width="100%" />
|
||||
<rect className="grade-bar--passing" width={`${passingGrade}%`} />
|
||||
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} />
|
||||
|
||||
{/* Start divider */}
|
||||
<rect className="grade-bar__divider" />
|
||||
{/* End divider */}
|
||||
<rect className="grade-bar__divider" x="99.7%" />
|
||||
</g>
|
||||
<PassingGradeTooltip passingGrade={passingGrade} tooltipClassName={lockedTooltipClassName} />
|
||||
<CurrentGradeTooltip tooltipClassName={lockedTooltipClassName} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GradeBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradeBar);
|
||||
@@ -0,0 +1,52 @@
|
||||
.grade-bar {
|
||||
rect {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.grade-bar__base {
|
||||
fill: $light-300;
|
||||
}
|
||||
|
||||
.grade-bar__divider {
|
||||
fill: $primary-500;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.grade-bar--passing {
|
||||
fill: $primary-500;
|
||||
}
|
||||
|
||||
.grade-bar--current-passing {
|
||||
fill: $success-500;
|
||||
}
|
||||
|
||||
.grade-bar--current-non-passing {
|
||||
fill: $accent-b;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
#minimum-grade-tooltip {
|
||||
.arrow::after {
|
||||
border-bottom-color: $primary-500;
|
||||
}
|
||||
}
|
||||
|
||||
#passing-grade-tooltip {
|
||||
.arrow::after {
|
||||
border-top-color: $success-500;
|
||||
}
|
||||
|
||||
background: $success-500;
|
||||
}
|
||||
|
||||
#non-passing-grade-tooltip {
|
||||
.arrow::after {
|
||||
border-top-color: $accent-b;
|
||||
}
|
||||
|
||||
background: $accent-b;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { InfoOutline } from '@edx/paragon/icons';
|
||||
import {
|
||||
Icon, IconButton, OverlayTrigger, Popover,
|
||||
} from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const gradeRangeEntries = Object.entries(gradeRange);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
trigger="click"
|
||||
show={showTooltip}
|
||||
overlay={(
|
||||
<Popover>
|
||||
<Popover.Content className="px-3">
|
||||
{intl.formatMessage(messages.courseGradeRangeTooltip)}
|
||||
<ul className="list-unstyled m-0">
|
||||
{gradeRangeEntries.map((entry, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<li key={entry[0]}>
|
||||
{entry[0]}: {(entry[1] * 100).toFixed(0)}%-100%
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const previousGrade = gradeRangeEntries[index - 1];
|
||||
return (
|
||||
<li key={entry[0]}>
|
||||
{entry[0]}: {(entry[1] * 100).toFixed(0)}%-{(previousGrade[1] * 100).toFixed(0)}%
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li>F: {'<'}{passingGrade}%</li>
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
onBlur={() => setShowTooltip(false)}
|
||||
alt={intl.formatMessage(messages.gradeRangeTooltipAlt)}
|
||||
className={`mb-0 mt-n1 ${iconButtonClassName}`}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
GradeRangeTooltip.defaultProps = {
|
||||
iconButtonClassName: '',
|
||||
};
|
||||
|
||||
GradeRangeTooltip.propTypes = {
|
||||
iconButtonClassName: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradeRangeTooltip);
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { OverlayTrigger, Popover } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
|
||||
return (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
show
|
||||
placement="bottom"
|
||||
overlay={(
|
||||
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
|
||||
<Popover.Content className="text-white">
|
||||
{passingGrade}%
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<g>
|
||||
<circle cx={`${passingGrade}%`} cy="50%" r="8.5" fill="transparent" />
|
||||
<circle className="grade-bar--passing" cx={`${passingGrade}%`} cy="50%" r="4.5" />
|
||||
</g>
|
||||
</OverlayTrigger>
|
||||
|
||||
<text
|
||||
className="x-small"
|
||||
textAnchor={passingGrade < 50 ? 'start' : 'end'}
|
||||
x={`${passingGrade}%`}
|
||||
y="90px"
|
||||
style={{ transform: `translateX(${passingGrade < 50 ? '' : '-'}3.4em)` }}
|
||||
>
|
||||
{intl.formatMessage(messages.passingGradeLabel)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PassingGradeTooltip.defaultProps = {
|
||||
tooltipClassName: '',
|
||||
};
|
||||
|
||||
PassingGradeTooltip.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
tooltipClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(PassingGradeTooltip);
|
||||
@@ -31,15 +31,15 @@ function DetailedGrades({ intl }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="text-dark-700 mb-4">
|
||||
<section className="text-dark-700">
|
||||
<h3 className="h4 mb-3">{intl.formatMessage(messages.detailedGrades)}</h3>
|
||||
{hasSectionScores && (
|
||||
<DetailedGradesTable sectionScores={sectionScores} />
|
||||
)}
|
||||
{!hasSectionScores && (
|
||||
<p className="small">You currently have no graded problem scores.</p>
|
||||
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
|
||||
)}
|
||||
<p className="x-small">
|
||||
<p className="x-small m-0">
|
||||
<FormattedMessage
|
||||
id="progress.ungradedAlert"
|
||||
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
|
||||
|
||||
@@ -19,7 +19,7 @@ function AssignmentTypeCell({ assignmentType, footnoteMarker, footnoteId }) {
|
||||
AssignmentTypeCell.propTypes = {
|
||||
assignmentType: PropTypes.string.isRequired,
|
||||
footnoteId: PropTypes.string,
|
||||
footnoteMarker: PropTypes.string,
|
||||
footnoteMarker: PropTypes.number,
|
||||
};
|
||||
|
||||
AssignmentTypeCell.defaultProps = {
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Popover } from '@edx/paragon';
|
||||
import {
|
||||
Icon, IconButton, OverlayTrigger, Popover,
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function GradeSummaryHeader({ intl }) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
return (
|
||||
<div className="row w-100 m-0 align-items-center">
|
||||
<h3 className="h4 mb-3 mr-2">{intl.formatMessage(messages.gradeSummary)}</h3>
|
||||
<h3 className="h4 mb-3 mr-1">{intl.formatMessage(messages.gradeSummary)}</h3>
|
||||
<OverlayTrigger
|
||||
trigger={['hover', 'click']}
|
||||
trigger="click"
|
||||
placement="top"
|
||||
show={showTooltip}
|
||||
overlay={(
|
||||
<Popover>
|
||||
<Popover.Content className="small text-dark-700">
|
||||
{intl.formatMessage(messages.gradeSummaryTooltip)}
|
||||
{intl.formatMessage(messages.gradeSummaryTooltipBody)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
>
|
||||
<Icon src={InfoOutline} className="mb-3" style={{ height: '1rem', width: '1rem' }} />
|
||||
<IconButton
|
||||
onClick={() => { setShowTooltip(!showTooltip); }}
|
||||
onBlur={() => { setShowTooltip(false); }}
|
||||
alt={intl.formatMessage(messages.gradeSummaryTooltipAlt)}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
className="mb-3"
|
||||
size="sm"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,28 +9,92 @@ const messages = defineMessages({
|
||||
id: 'progress.footnotes.backToContent',
|
||||
defaultMessage: 'Back to content',
|
||||
},
|
||||
courseGradeBody: {
|
||||
id: 'progress.courseGrade.body',
|
||||
defaultMessage: 'This represents your weighted grade against the grade needed to pass this course.',
|
||||
},
|
||||
courseGradeBarAltText: {
|
||||
id: 'progress.courseGrade.gradeBar.altText',
|
||||
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
|
||||
},
|
||||
courseGradeFooterGenericPassing: {
|
||||
id: 'progress.courseGrade.footer.generic.passing',
|
||||
defaultMessage: 'You’re currently passing this course',
|
||||
},
|
||||
courseGradeFooterNonPassing: {
|
||||
id: 'progress.courseGrade.footer.nonPassing',
|
||||
defaultMessage: 'A weighted grade of {passingGrade}% is required to pass in this course',
|
||||
},
|
||||
courseGradeFooterPassingWithGrade: {
|
||||
id: 'progress.courseGrade.footer.passing',
|
||||
defaultMessage: 'You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)',
|
||||
},
|
||||
courseGradePreviewHeader: {
|
||||
id: 'progress.courseGrade.preview.header',
|
||||
defaultMessage: 'locked feature',
|
||||
},
|
||||
courseGradePreviewHeaderAriaHidden: {
|
||||
id: 'progress.courseGrade.preview.header.ariaHidden',
|
||||
defaultMessage: 'Preview of a ',
|
||||
},
|
||||
courseGradePreviewBody: {
|
||||
id: 'progress.courseGrade.preview.body',
|
||||
defaultMessage: 'Unlock to view grades and work towards a certificate',
|
||||
},
|
||||
courseGradePreviewUpgradeButton: {
|
||||
id: 'progress.courseGrade.preview.button.upgrade',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
courseGradeRangeTooltip: {
|
||||
id: 'progress.courseGrade.gradeRange.tooltip',
|
||||
defaultMessage: 'Grade ranges for this course:',
|
||||
},
|
||||
courseOutline: {
|
||||
id: 'progress.courseOutline',
|
||||
defaultMessage: 'Course Outline',
|
||||
},
|
||||
currentGradeLabel: {
|
||||
id: 'progress.courseGrade.label.currentGrade',
|
||||
defaultMessage: 'Your current grade',
|
||||
},
|
||||
detailedGrades: {
|
||||
id: 'progress.detailedGrades',
|
||||
defaultMessage: 'Detailed grades',
|
||||
},
|
||||
detailedGradesEmpty: {
|
||||
id: 'progress.detailedGrades.emptyTable',
|
||||
defaultMessage: 'You currently have no graded problem scores.',
|
||||
},
|
||||
footnotesTitle: {
|
||||
id: 'progress.footnotes.title',
|
||||
defaultMessage: 'Grade summary footnotes',
|
||||
},
|
||||
grades: {
|
||||
id: 'progress.courseGrade.grades',
|
||||
defaultMessage: 'Grades',
|
||||
},
|
||||
gradeRangeTooltipAlt: {
|
||||
id: 'progress.courseGrade.gradeRange.Tooltip',
|
||||
defaultMessage: 'Grade range tooltip',
|
||||
},
|
||||
gradeSummary: {
|
||||
id: 'progress.gradeSummary',
|
||||
defaultMessage: 'Grade summary',
|
||||
},
|
||||
gradeSummaryTooltip: {
|
||||
id: 'progress.gradeSummary.tooltip',
|
||||
gradeSummaryTooltipAlt: {
|
||||
id: 'progress.gradeSummary.tooltip.alt',
|
||||
defaultMessage: 'Grade summary tooltip',
|
||||
},
|
||||
gradeSummaryTooltipBody: {
|
||||
id: 'progress.gradeSummary.tooltip.body',
|
||||
defaultMessage: "Your course assignment's weight is determined by your instructor. "
|
||||
+ 'By multiplying your score by the weight for that assignment type, your weighted grade is calculated. '
|
||||
+ "Your weighted grade is what's used to determine if you pass the course.",
|
||||
},
|
||||
passingGradeLabel: {
|
||||
id: 'progress.courseGrade.label.passingGrade',
|
||||
defaultMessage: 'Passing grade',
|
||||
},
|
||||
score: {
|
||||
id: 'progress.score',
|
||||
defaultMessage: 'Score',
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
it('should navigate between units and check block completion', async () => {
|
||||
axiosMock.onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/xmodule_handler/get_completion`).reply(200, {
|
||||
axiosMock.onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/get_completion`).reply(200, {
|
||||
complete: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
@@ -13,8 +14,11 @@ import Sequence from './sequence';
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import SidebarNotificationButton from './SidebarNotificationButton';
|
||||
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
|
||||
@@ -57,6 +61,19 @@ function Course({
|
||||
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 shouldDisplaySidebarButton = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const [sidebarVisible, setSidebar] = useState(false);
|
||||
const isSidebarVisible = () => sidebarVisible && setSidebar;
|
||||
const toggleSidebar = () => {
|
||||
if (sidebarVisible) { setSidebar(false); } else { setSidebar(true); }
|
||||
};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
|
||||
|
||||
@@ -76,13 +93,23 @@ function Course({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
<div className="position-relative">
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
{ isValuePropCookieSet && shouldDisplaySidebarButton ? (
|
||||
<SidebarNotificationButton
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AlertList topic="sequence" />
|
||||
<Sequence
|
||||
unitId={unitId}
|
||||
@@ -91,6 +118,10 @@ function Course({
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
sidebarVisible={sidebarVisible}
|
||||
isValuePropCookieSet={isValuePropCookieSet}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -19,6 +20,7 @@ describe('Course', () => {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
unitNavigationHandler: () => {},
|
||||
toggleSidebar: () => {},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -85,6 +87,31 @@ 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();
|
||||
|
||||
// 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 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: {
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function CourseBreadcrumbs({
|
||||
}, [courseStatus, sequenceStatus]);
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="my-4">
|
||||
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
|
||||
<ol className="list-unstyled d-flex m-0">
|
||||
<CourseBreadcrumb
|
||||
url={`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`}
|
||||
|
||||
28
src/courseware/course/NotificationIcon.jsx
Normal file
28
src/courseware/course/NotificationIcon.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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 { WatchOutline } from '@edx/paragon/icons';
|
||||
|
||||
import './NotificationIcon.scss';
|
||||
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)} />
|
||||
{status === 'active'
|
||||
? <span className={classNames(notificationColor, 'notification-dot')} data-testid="notification-dot" />
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationIcon.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
notificationColor: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationIcon);
|
||||
15
src/courseware/course/NotificationIcon.scss
Normal file
15
src/courseware/course/NotificationIcon.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 2.4rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.notification-dot {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.55rem;
|
||||
border-radius: 50% !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
51
src/courseware/course/Sidebar.jsx
Normal file
51
src/courseware/course/Sidebar.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
68
src/courseware/course/Sidebar.scss
Normal file
68
src/courseware/course/Sidebar.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
.sidebar-container {
|
||||
border: 1px solid $light-400;
|
||||
border-radius: 4px;
|
||||
width: 20rem;
|
||||
vertical-align: top;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'lg')) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100% !important;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.no-notification {
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0.625rem 0;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
width: 100.5%;
|
||||
height: 0.5rem;
|
||||
background: $gray-100;
|
||||
border: 1px solid $light-400;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mobile-close-container {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid $light-400;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
svg {
|
||||
top: 0.4rem;
|
||||
left: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-close {
|
||||
font-weight: 500;
|
||||
margin-left: 1.2rem;
|
||||
}
|
||||
58
src/courseware/course/Sidebar.test.jsx
Normal file
58
src/courseware/course/Sidebar.test.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
29
src/courseware/course/SidebarNotificationButton.jsx
Normal file
29
src/courseware/course/SidebarNotificationButton.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 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);
|
||||
19
src/courseware/course/SidebarNotificationButton.scss
Normal file
19
src/courseware/course/SidebarNotificationButton.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.sidebar-notification-btn {
|
||||
border: 1px solid $light-400;
|
||||
background: none;
|
||||
margin-top: 0.625rem;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
border: none;
|
||||
margin: 0.3rem 1.25rem 0 0.25rem;
|
||||
top: 0.1rem;
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border-bottom: 3px solid $primary-700;
|
||||
}
|
||||
43
src/courseware/course/SidebarNotificationButton.test.jsx
Normal file
43
src/courseware/course/SidebarNotificationButton.test.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import DashboardFootnote from './DashboardFootnote';
|
||||
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';
|
||||
|
||||
const LINKEDIN_BLUE = '#2867B2';
|
||||
@@ -45,6 +46,7 @@ function CourseCelebration({ intl }) {
|
||||
certificateData,
|
||||
end,
|
||||
linkedinAddToProfileUrl,
|
||||
marketingUrl,
|
||||
offer,
|
||||
org,
|
||||
relatedPrograms,
|
||||
@@ -58,41 +60,18 @@ function CourseCelebration({ intl }) {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
downloadUrl,
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
/** [WS-1681 experiment] */
|
||||
const [showWS1681, setShowWS1681] = useState(window.experiment__courseware_celebration_bShowWS1681);
|
||||
useEffect(() => { setShowWS1681(window.experiment__courseware_celebration_bShowWS1681); });
|
||||
|
||||
const { administrator, username } = getAuthenticatedUser();
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const dashboardLink = (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
>
|
||||
{intl.formatMessage(messages.dashboardLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
const idVerificationSupportLink = getConfig().SUPPORT_URL_ID_VERIFICATION && (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={getConfig().SUPPORT_URL_ID_VERIFICATION}
|
||||
>
|
||||
{intl.formatMessage(messages.idVerificationSupportLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
const profileLink = (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
|
||||
>
|
||||
{intl.formatMessage(messages.profileLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
const dashboardLink = <DashboardLink />;
|
||||
const idVerificationSupportLink = <IdVerificationSupportLink />;
|
||||
const profileLink = <ProfileLink />;
|
||||
|
||||
let buttonPrefix = null;
|
||||
let buttonLocation;
|
||||
@@ -147,25 +126,20 @@ function CourseCelebration({ intl }) {
|
||||
break;
|
||||
case 'earned_but_not_available': {
|
||||
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||
const certAvailableDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
|
||||
message = (
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="After this course officially ends on {endDate}, you will receive an
|
||||
email notification with your certificate. Once you have your certificate, be sure
|
||||
to showcase your accomplishment on LinkedIn or your resumé."
|
||||
values={{ endDate }}
|
||||
defaultMessage="This course ended on {endDate} and final grades and certificates are scheduled to be
|
||||
available after {certAvailableDate}."
|
||||
values={{ endDate, certAvailableDate }}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.accessCertificate"
|
||||
defaultMessage="You will be able to access your certificate any time from your
|
||||
{dashboardLink} and {profileLink}."
|
||||
values={{ dashboardLink, profileLink }}
|
||||
/>
|
||||
{intl.formatMessage(messages.certificateNotAvailableBodyAccessCert)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
@@ -287,7 +261,8 @@ function CourseCelebration({ intl }) {
|
||||
{intl.formatMessage(messages.congratulationsHeader)}
|
||||
</div>
|
||||
<div className="col-12 p-0 font-weight-normal lead text-center">
|
||||
{intl.formatMessage(messages.shareHeader)}
|
||||
{intl.formatMessage(messages.completedCourseHeader)}
|
||||
{marketingUrl && ` ${intl.formatMessage(messages.shareMessage)}`}
|
||||
<SocialIcons
|
||||
analyticsId="edx.ui.lms.course_exit.social_share.clicked"
|
||||
className="mt-2"
|
||||
@@ -315,7 +290,7 @@ function CourseCelebration({ intl }) {
|
||||
</div>
|
||||
<div className="col-12 px-0 px-md-5">
|
||||
{certHeader && (
|
||||
<Alert variant="primary" className="row w-100 m-0">
|
||||
<Alert variant="success" className="row w-100 m-0">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{certHeader}</div>
|
||||
{message}
|
||||
|
||||
@@ -12,9 +12,25 @@ import CourseNonPassing from './CourseNonPassing';
|
||||
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CourseExit({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const mode = getCourseExitMode(courseId);
|
||||
const {
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
);
|
||||
|
||||
let body = null;
|
||||
if (mode === COURSE_EXIT_MODES.nonPassing) {
|
||||
|
||||
@@ -115,9 +115,14 @@ describe('Course Exit Pages', () => {
|
||||
});
|
||||
|
||||
it('Displays certificate is earned but unavailable message', async () => {
|
||||
setMetadata({ certificate_data: { cert_status: 'earned_but_not_available' } });
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'earned_but_not_available',
|
||||
certificate_available_date: '2021-05-21T12:00:00Z',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your certificate will be available soon!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays request certificate link', async () => {
|
||||
|
||||
@@ -13,7 +13,12 @@ const messages = defineMessages({
|
||||
},
|
||||
certificateHeaderNotAvailable: {
|
||||
id: 'courseCelebration.certificateHeader.notAvailable',
|
||||
defaultMessage: 'Your certificate will be available soon!',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
description: 'Header displayed when course certificate is not yet available to be viewed',
|
||||
},
|
||||
certificateNotAvailableBodyAccessCert: {
|
||||
id: 'courseCelebration.certificateBody.notAvailable.accessCertificate',
|
||||
defaultMessage: 'If you have earned a passing grade, your certificate will be automatically issued.',
|
||||
description: 'Text displayed when course certificate is not yet available to be viewed',
|
||||
},
|
||||
certificateHeaderUnverified: {
|
||||
@@ -35,6 +40,10 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Sample certificate',
|
||||
description: 'Alt text used to describe an image of a certificate',
|
||||
},
|
||||
completedCourseHeader: {
|
||||
id: 'courseCelebration.completedCourseHeader',
|
||||
defaultMessage: 'You have completed your course.',
|
||||
},
|
||||
congratulationsHeader: {
|
||||
id: 'courseCelebration.congratulationsHeader',
|
||||
defaultMessage: 'Congratulations!',
|
||||
@@ -127,9 +136,9 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Search our catalog',
|
||||
description: 'First part of a sentence that continues afterward',
|
||||
},
|
||||
shareHeader: {
|
||||
id: 'courseCelebration.shareHeader',
|
||||
defaultMessage: 'You have completed your course. Share your success on social media or email.',
|
||||
shareMessage: {
|
||||
id: 'courseCelebration.shareMessage',
|
||||
defaultMessage: 'Share your success on social media or email.',
|
||||
},
|
||||
socialMessage: {
|
||||
id: 'courseExit.social.shareCompletionMessage',
|
||||
@@ -176,6 +185,7 @@ const messages = defineMessages({
|
||||
id: 'courseExit.viewGradesButton',
|
||||
defaultMessage: 'View grades',
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const COURSE_EXIT_MODES = {
|
||||
disabled: 0,
|
||||
@@ -26,18 +25,16 @@ const NON_CERTIFICATE_STATUSES = [ // no certificate will be given, though a val
|
||||
'honor_passing', // provided when honor is configured to not give a certificate
|
||||
];
|
||||
|
||||
function getCourseExitMode(courseId) {
|
||||
const {
|
||||
certificateData,
|
||||
courseExitPageIsActive,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
function getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive = null,
|
||||
) {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
if (!courseExitPageIsActive || !authenticatedUser || !isEnrolled) {
|
||||
if (courseExitPageIsActive === false || !authenticatedUser || !isEnrolled) {
|
||||
return COURSE_EXIT_MODES.disabled;
|
||||
}
|
||||
|
||||
@@ -69,7 +66,20 @@ function getCourseExitMode(courseId) {
|
||||
|
||||
// Returns null in order to render the default navigation text
|
||||
function getCourseExitNavigation(courseId, intl) {
|
||||
const exitMode = getCourseExitMode(courseId);
|
||||
const {
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
const exitMode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
);
|
||||
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
|
||||
|
||||
let exitText;
|
||||
|
||||
36
src/courseware/course/messages.js
Normal file
36
src/courseware/course/messages.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
sidebarNotification: {
|
||||
id: 'sidebar.notification.container',
|
||||
defaultMessage: 'Sidebar notification',
|
||||
description: 'Sidebar notification section container',
|
||||
},
|
||||
openSidebarButton: {
|
||||
id: 'sidebar.open.button',
|
||||
defaultMessage: 'Show sidebar notification',
|
||||
description: 'Button to open the sidebar and show notifications',
|
||||
},
|
||||
closeSidebarButton: {
|
||||
id: 'sidebar.close.button',
|
||||
defaultMessage: 'Close sidebar notification',
|
||||
description: 'Button for the learner to close the sidebar',
|
||||
},
|
||||
responsiveCloseSidebar: {
|
||||
id: 'sidebar.responsive.close.button',
|
||||
defaultMessage: 'Back to course',
|
||||
description: 'Responsive button for the learner to go back to course and close the sidebar',
|
||||
},
|
||||
notificationTitle: {
|
||||
id: 'sidebar.notification.title',
|
||||
defaultMessage: 'Notifications',
|
||||
description: 'Title text displayed for sidebar notifications',
|
||||
},
|
||||
noNotificationsMessage: {
|
||||
id: 'sidebar.notification.no.message',
|
||||
defaultMessage: 'You have no new notifications at this time.',
|
||||
description: 'Text displayed when the learner has no notifications',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,6 +3,7 @@ import React, {
|
||||
useEffect, useContext, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
sendTrackEvent,
|
||||
sendTrackingLogEvent,
|
||||
@@ -13,12 +14,15 @@ import { history } from '@edx/frontend-platform';
|
||||
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../../generic/tabs/useWindowSize';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
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';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { isMobile } from '../../../experiments/mm-p2p/utils';
|
||||
@@ -32,12 +36,18 @@ function Sequence({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
intl,
|
||||
toggleSidebar,
|
||||
sidebarVisible,
|
||||
isSidebarVisible,
|
||||
isValuePropCookieSet,
|
||||
mmp2p,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const handleNext = () => {
|
||||
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
|
||||
if (nextIndex < sequence.unitIds.length) {
|
||||
@@ -145,7 +155,7 @@ function Sequence({
|
||||
return (
|
||||
<div>
|
||||
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
||||
<div className="sequence" style={{ width: '100%' }}>
|
||||
<div className={classNames('sequence', { 'position-relative': shouldDisplaySidebarButton })} style={{ width: '100%' }}>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
@@ -167,7 +177,16 @@ function Sequence({
|
||||
handlePrevious();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
isValuePropCookieSet={isValuePropCookieSet}
|
||||
/>
|
||||
|
||||
{isValuePropCookieSet && shouldDisplaySidebarButton ? (
|
||||
<SidebarNotificationButton
|
||||
toggleSidebar={toggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
courseId={courseId}
|
||||
@@ -195,6 +214,12 @@ function Sequence({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sidebarVisible ? (
|
||||
<Sidebar
|
||||
toggleSidebar={toggleSidebar}
|
||||
sidebarVisible={sidebarVisible}
|
||||
/>
|
||||
) : null }
|
||||
|
||||
{/** [MM-P2P] Experiment */}
|
||||
{(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && (
|
||||
@@ -224,6 +249,10 @@ 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,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
@@ -242,6 +271,10 @@ Sequence.propTypes = {
|
||||
Sequence.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
toggleSidebar: null,
|
||||
sidebarVisible: null,
|
||||
isSidebarVisible: null,
|
||||
isValuePropCookieSet: null,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: () => {},
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
sidebarVisible: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -129,6 +130,16 @@ 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(
|
||||
|
||||
@@ -23,6 +23,20 @@ import { MMP2PLockPaywall } from '../../../experiments/mm-p2p';
|
||||
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||
);
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
@@ -155,12 +169,12 @@ function Unit({
|
||||
: (
|
||||
<iframe
|
||||
title={modalOptions.title}
|
||||
allow="microphone *; camera *; midi *; geolocation *; encrypted-media *"
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.url}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -178,6 +192,7 @@ function Unit({
|
||||
id="unit-iframe"
|
||||
title={unit.title}
|
||||
src={iframeUrl}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Button, Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
import VerifiedCert from '../../../../generic/assets/edX_certificate.png';
|
||||
import certificateLocked from '../../../../generic/assets/edX_locked_certificate.png';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import './LockPaywall.scss';
|
||||
|
||||
function LockPaywall({
|
||||
intl,
|
||||
@@ -42,33 +46,128 @@ function LockPaywall({
|
||||
pageName: 'in_course',
|
||||
});
|
||||
};
|
||||
|
||||
const lockIcon = (
|
||||
<Icon
|
||||
className="float-left"
|
||||
src={Locked}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
|
||||
const verifiedCertLink = (
|
||||
<Alert.Link
|
||||
href="https://www.edx.org/verified-certificate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{intl.formatMessage(messages['learn.lockPaywall.list.bullet1.linktext'])}
|
||||
</Alert.Link>
|
||||
);
|
||||
|
||||
const gradedAssignments = (
|
||||
<span className="font-weight-bold">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.list.bullet2.boldtext'])}
|
||||
</span>
|
||||
);
|
||||
const fullAccess = (
|
||||
<span className="font-weight-bold">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.list.bullet3.boldtext'])}
|
||||
</span>
|
||||
);
|
||||
const nonProfit = (
|
||||
<span className="font-weight-bold">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.list.bullet4.boldtext'])}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-gray rounded d-flex justify-content-between mt-2 p-3">
|
||||
<div>
|
||||
<h4 className="font-weight-bold mb-2">
|
||||
<FontAwesomeIcon icon={faLock} className="text-black mr-2 ml-1" style={{ fontSize: '2rem' }} />
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
|
||||
</h4>
|
||||
<p className="mb-0">
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.content'])}</span>
|
||||
|
||||
<a className="lock_paywall_upgrade_link" href={upgradeUrl} onClick={logClick}>
|
||||
<Alert variant="light" aria-live="off">
|
||||
<div className="row">
|
||||
<div className="col-auto px-0">
|
||||
{lockIcon}
|
||||
</div>
|
||||
|
||||
<div className="col">
|
||||
<h4 aria-level="3">
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
|
||||
</h4>
|
||||
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content'])}
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-row flex-wrap">
|
||||
<div style={{ float: 'left' }} className="mr-3 mb-2">
|
||||
<img
|
||||
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
|
||||
src={certificateLocked}
|
||||
className="border-0 certificate-image-banner"
|
||||
style={{ height: '128px', width: '175px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mw-xs list-div">
|
||||
<div className="mb-2">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.list.intro'])}
|
||||
</div>
|
||||
<ul className="fa-ul ml-4 pl-2">
|
||||
<li>
|
||||
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="gatedContent.paragraph.bulletOne"
|
||||
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
|
||||
values={{ verifiedCertLink }}
|
||||
className="bullet-text"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="gatedContent.paragraph.bulletTwo"
|
||||
defaultMessage="Unlock access to all course activities, including {gradedAssignments}"
|
||||
values={{ gradedAssignments }}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="gatedContent.paragraph.bulletThree"
|
||||
defaultMessage="{fullAccess} to course content and materials, even after the course ends"
|
||||
values={{ fullAccess }}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
|
||||
<FormattedMessage
|
||||
id="gatedContent.paragraph.bulletFour"
|
||||
defaultMessage="Support our {nonProfit} mission at edX"
|
||||
values={{ nonProfit }}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="col-md-auto p-md-0 d-md-flex align-items-md-center mr-md-3"
|
||||
style={{ textAlign: 'right' }}
|
||||
>
|
||||
<Button
|
||||
className="lock_paywall_upgrade_link"
|
||||
href={upgradeUrl}
|
||||
onClick={logClick}
|
||||
role="link"
|
||||
>
|
||||
{intl.formatMessage(messages['learn.lockPaywall.upgrade.link'], {
|
||||
currencySymbol,
|
||||
price,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
|
||||
src={VerifiedCert}
|
||||
className="border-0"
|
||||
style={{ height: '70px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
LockPaywall.propTypes = {
|
||||
|
||||
13
src/courseware/course/sequence/lock-paywall/LockPaywall.scss
Normal file
13
src/courseware/course/sequence/lock-paywall/LockPaywall.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
// Temporary CSS intervention until paragon list items will support icons (PAR-429)
|
||||
.fa-li {
|
||||
left: -31px !important;
|
||||
padding-right: 22px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) and (max-width: 1100px) {
|
||||
.list-div {
|
||||
width: 62%;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -13,6 +12,7 @@ import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||
import { useSequenceNavigationMetadata } from './hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import { LOADED } from '../../../data/slice';
|
||||
import useWindowSize, { responsiveBreakpoints } from '../../../../generic/tabs/useWindowSize';
|
||||
|
||||
import messages from './messages';
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -27,6 +27,7 @@ function SequenceNavigation({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
goToCourseExitPage,
|
||||
isValuePropCookieSet,
|
||||
mmp2p,
|
||||
}) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
@@ -39,6 +40,8 @@ function SequenceNavigation({
|
||||
sequence.gatedContent !== undefined && sequence.gatedContent.gated
|
||||
) : undefined;
|
||||
|
||||
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const renderUnitButtons = () => {
|
||||
if (isLocked) {
|
||||
return (
|
||||
@@ -66,25 +69,22 @@ function SequenceNavigation({
|
||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||
const disabled = isLastUnit && !exitActive;
|
||||
return (
|
||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled}>
|
||||
{buttonText}
|
||||
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
|
||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={ChevronRight}>
|
||||
{isValuePropCookieSet && shouldDisplaySidebarButton ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
<nav className={classNames('sequence-navigation', className)}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
|
||||
{intl.formatMessage(messages.previousButton)}
|
||||
<nav className={classNames('sequence-navigation', className)} style={{ width: isValuePropCookieSet && shouldDisplaySidebarButton ? '90%' : null }}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={ChevronLeft}>
|
||||
{isValuePropCookieSet && shouldDisplaySidebarButton ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
{renderUnitButtons()}
|
||||
{renderNextButton()}
|
||||
|
||||
{/** [MM-P2P] Experiment */}
|
||||
{ mmp2p.state.isEnabled && <MMP2PFlyoverTriggerMobile options={mmp2p} /> }
|
||||
<div className="rev1512ToggleFlyoverSequenceLocation" />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,7 @@ 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({
|
||||
@@ -109,6 +110,7 @@ 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).toHaveClass('fa-edit');
|
||||
expect(unitButton.firstChild.firstChild).toHaveClass('fa-edit');
|
||||
});
|
||||
|
||||
it('renders correctly and handles unit button clicks', () => {
|
||||
|
||||
@@ -29,7 +29,6 @@ Factory.define('sequenceMetadata')
|
||||
.attr('element_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.block_id)
|
||||
.attr('item_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.id)
|
||||
.attr('display_name', ['sequenceBlock'], sequenceBlock => sequenceBlock.display_name)
|
||||
.attr('ajax_url', ['sequenceBlock'], sequenceBlock => `${sequenceBlock.student_view_url}/handler/xmodule_handler}`)
|
||||
.attr('gated_content', ['sequenceBlock'], sequenceBlock => ({
|
||||
gated: false,
|
||||
prereq_url: null,
|
||||
|
||||
@@ -133,6 +133,7 @@ function normalizeMetadata(metadata) {
|
||||
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,
|
||||
@@ -204,48 +205,22 @@ export async function getSequenceMetadata(sequenceId) {
|
||||
return normalizeSequenceMetadata(data);
|
||||
}
|
||||
|
||||
const getSequenceXModuleHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/xmodule_handler`;
|
||||
const getSequenceHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler`;
|
||||
|
||||
export async function getBlockCompletion(courseId, sequenceId, usageKey) {
|
||||
// Post data sent to this endpoint must be url encoded
|
||||
// TODO: Remove the need for this to be the case.
|
||||
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
|
||||
const urlEncoded = new URLSearchParams();
|
||||
urlEncoded.append('usage_key', usageKey);
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
`${getSequenceXModuleHandlerUrl(courseId, sequenceId)}/get_completion`,
|
||||
urlEncoded.toString(),
|
||||
requestConfig,
|
||||
`${getSequenceHandlerUrl(courseId, sequenceId)}/get_completion`,
|
||||
{ usage_key: usageKey },
|
||||
);
|
||||
|
||||
if (data.complete) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return data.complete === true;
|
||||
}
|
||||
|
||||
export async function postSequencePosition(courseId, sequenceId, activeUnitIndex) {
|
||||
// Post data sent to this endpoint must be url encoded
|
||||
// TODO: Remove the need for this to be the case.
|
||||
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
|
||||
const urlEncoded = new URLSearchParams();
|
||||
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
|
||||
urlEncoded.append('position', activeUnitIndex + 1);
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
`${getSequenceXModuleHandlerUrl(courseId, sequenceId)}/goto_position`,
|
||||
urlEncoded.toString(),
|
||||
requestConfig,
|
||||
`${getSequenceHandlerUrl(courseId, sequenceId)}/goto_position`,
|
||||
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
|
||||
{ position: activeUnitIndex + 1 },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test checkBlockCompletion', () => {
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/xmodule_handler/get_completion`;
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`;
|
||||
|
||||
it('Should fail to check completion and log error', async () => {
|
||||
axiosMock.onPost(getCompletionURL).networkError();
|
||||
@@ -227,7 +227,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test saveSequencePosition', () => {
|
||||
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/xmodule_handler/goto_position`;
|
||||
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`;
|
||||
|
||||
it('Should change and revert sequence model activeUnitIndex in case of error', async () => {
|
||||
axiosMock.onPost(gotoPositionURL).networkError();
|
||||
|
||||
@@ -49,6 +49,14 @@ export default class CourseSock extends Component {
|
||||
location: 'sock',
|
||||
...this.commonEventProperties,
|
||||
});
|
||||
const onCourseHome = this.props.pageLocation === 'Home Page';
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
...this.commonEventProperties,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: onCourseHome ? 'course_home_sock' : 'in_course_sock',
|
||||
linkType: 'button',
|
||||
pageName: onCourseHome ? 'course_home' : 'in_course',
|
||||
});
|
||||
}
|
||||
|
||||
showToUser = () => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
render, screen, fireEvent, initializeMockApp,
|
||||
render, screen, fireEvent, initializeMockApp, initializeTestStore,
|
||||
} from '../../setupTest';
|
||||
import CourseSock from './CourseSock';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Course Sock', () => {
|
||||
let store;
|
||||
const mockData = {
|
||||
verifiedMode: {
|
||||
upgradeUrl: 'test-url',
|
||||
@@ -20,6 +22,9 @@ describe('Course Sock', () => {
|
||||
beforeAll(async () => {
|
||||
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
|
||||
await initializeMockApp();
|
||||
store = await initializeTestStore();
|
||||
const { courseware } = store.getState();
|
||||
mockData.courseId = courseware.courseId;
|
||||
});
|
||||
|
||||
it('hides upsell information on load', () => {
|
||||
@@ -29,16 +34,49 @@ describe('Course Sock', () => {
|
||||
expect(screen.queryByText('edX Verified Certificate')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click', () => {
|
||||
it.only('handles click', () => {
|
||||
sendTrackEvent.mockClear();
|
||||
render(<CourseSock {...mockData} />);
|
||||
const upsellButton = screen.getByRole('button', { name: 'Learn About Verified Certificates' });
|
||||
fireEvent.click(upsellButton);
|
||||
const learnMoreButton = screen.getByRole('button', { name: 'Learn About Verified Certificates' });
|
||||
fireEvent.click(learnMoreButton);
|
||||
|
||||
expect(screen.getByText('edX Verified Certificate')).toBeInTheDocument();
|
||||
const { currencySymbol, price } = mockData.verifiedMode;
|
||||
expect(screen.getByText(`Upgrade for ${currencySymbol}${price}`)).toBeInTheDocument();
|
||||
|
||||
const upsellButton = screen.getByText(`Upgrade for ${currencySymbol}${price}`);
|
||||
expect(upsellButton).toBeInTheDocument();
|
||||
fireEvent.click(upsellButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
|
||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course_1',
|
||||
creative: 'original_sock',
|
||||
name: 'In-Course Verification Prompt',
|
||||
org_key: null,
|
||||
position: 'sock',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.course.sock.toggle_closed', {
|
||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course_1',
|
||||
from_page: 'Course Content Page',
|
||||
org_key: null,
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Clicked', {
|
||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course_1',
|
||||
creative: 'original_sock',
|
||||
name: 'In-Course Verification Prompt',
|
||||
org_key: null,
|
||||
position: 'sock',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: null,
|
||||
courserun_key: mockData.courseId,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: 'in_course_sock',
|
||||
linkType: 'button',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
fireEvent.click(learnMoreButton);
|
||||
expect(screen.queryByText('edX Verified Certificate')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// NOTE: These are the breakpoints used in Bootstrap v4.0.0 as seen in
|
||||
// the documentation (https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints)
|
||||
export const responsiveBreakpoints = {
|
||||
extraSmall: {
|
||||
maxWidth: 575.98,
|
||||
},
|
||||
small: {
|
||||
minWidth: 576,
|
||||
maxWidth: 767.98,
|
||||
},
|
||||
medium: {
|
||||
minWidth: 768,
|
||||
maxWidth: 991.98,
|
||||
},
|
||||
large: {
|
||||
minWidth: 992,
|
||||
maxWidth: 1199.98,
|
||||
},
|
||||
extraLarge: {
|
||||
minWidth: 1200,
|
||||
},
|
||||
};
|
||||
|
||||
export default function useWindowSize() {
|
||||
const isClient = typeof global === 'object';
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ function FormattedPricing(props) {
|
||||
verifiedMode,
|
||||
} = props;
|
||||
|
||||
let currencySymbol;
|
||||
if (verifiedMode) {
|
||||
currencySymbol = verifiedMode.currencySymbol;
|
||||
}
|
||||
|
||||
if (!offer) {
|
||||
const {
|
||||
currencySymbol,
|
||||
price,
|
||||
} = verifiedMode;
|
||||
return `${currencySymbol}${price}`;
|
||||
@@ -49,7 +53,7 @@ function FormattedPricing(props) {
|
||||
{intl.formatMessage(messages.srPrices, { discountedPrice, originalPrice })}
|
||||
</span>
|
||||
<span aria-hidden="true">
|
||||
<span>{discountedPrice}</span> (<del>{originalPrice}</del>)
|
||||
<span>{currencySymbol}{discountedPrice}</span> (<del>{currencySymbol}{originalPrice}</del>)
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ function UpgradeButton(props) {
|
||||
const {
|
||||
intl,
|
||||
offer,
|
||||
variant,
|
||||
onClick,
|
||||
verifiedMode,
|
||||
...rest
|
||||
@@ -19,7 +20,7 @@ function UpgradeButton(props) {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant={variant}
|
||||
href={url}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
@@ -43,6 +44,7 @@ function UpgradeButton(props) {
|
||||
UpgradeButton.defaultProps = {
|
||||
offer: null,
|
||||
onClick: null,
|
||||
variant: 'primary',
|
||||
};
|
||||
|
||||
UpgradeButton.propTypes = {
|
||||
@@ -54,6 +56,7 @@ UpgradeButton.propTypes = {
|
||||
verifiedMode: PropTypes.shape({
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
variant: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeButton);
|
||||
|
||||
62
src/generic/upgrade-button/UpgradeNowButton.jsx
Normal file
62
src/generic/upgrade-button/UpgradeNowButton.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import FormattedPricing from './FormattedPricing';
|
||||
|
||||
function UpgradeNowButton(props) {
|
||||
const {
|
||||
intl,
|
||||
offer,
|
||||
variant,
|
||||
onClick,
|
||||
verifiedMode,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
// Prefer offer's url in case it is ever different (though it is not at time of this writing)
|
||||
const url = offer ? offer.upgradeUrl : verifiedMode.upgradeUrl;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
href={url}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="learning.upgradeNowButton.buttonText"
|
||||
defaultMessage="Upgrade now for {pricing}"
|
||||
values={{
|
||||
pricing: (
|
||||
<FormattedPricing
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeNowButton.defaultProps = {
|
||||
offer: null,
|
||||
onClick: null,
|
||||
variant: 'primary',
|
||||
};
|
||||
|
||||
UpgradeNowButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
offer: PropTypes.shape({
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}),
|
||||
onClick: PropTypes.func,
|
||||
verifiedMode: PropTypes.shape({
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
variant: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeNowButton);
|
||||
@@ -1,7 +1,9 @@
|
||||
import FormattedPricing from './FormattedPricing';
|
||||
import UpgradeButton from './UpgradeButton';
|
||||
import UpgradeNowButton from './UpgradeNowButton';
|
||||
|
||||
export {
|
||||
FormattedPricing,
|
||||
UpgradeButton,
|
||||
UpgradeNowButton,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,12 @@ function getAlertIcon(type) {
|
||||
return faInfoCircle;
|
||||
}
|
||||
|
||||
function getAlertIconColor(type) {
|
||||
if (type === ALERT_TYPES.SUCCESS) {
|
||||
return 'text-success-500';
|
||||
}
|
||||
}
|
||||
|
||||
function Alert({
|
||||
type, dismissible, children, footer, intl, onDismiss,
|
||||
}) {
|
||||
@@ -49,7 +55,7 @@ function Alert({
|
||||
<div className="row w-100 m-0">
|
||||
{type !== ALERT_TYPES.WELCOME && (
|
||||
<div className="col-auto p-0 mr-3">
|
||||
<FontAwesomeIcon icon={getAlertIcon(type)} />
|
||||
<FontAwesomeIcon icon={getAlertIcon(type)} className={getAlertIconColor(type)}/>
|
||||
</div>
|
||||
)}
|
||||
<div className="col mr-4 p-0 align-items-start">
|
||||
|
||||
@@ -87,12 +87,12 @@
|
||||
"learning.proctoringPanel.message.notStarted": "You have not started your onboarding exam.",
|
||||
"learning.proctoringPanel.message.started": "You have started your onboarding exam.",
|
||||
"learning.proctoringPanel.message.submitted": "You have submitted your onboarding exam.",
|
||||
"learning.proctoringPanel.message.verified": "You can now take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.verified": "Your onboarding exam has been approved in this course.",
|
||||
"learning.proctoringPanel.message.rejected": "Your onboarding exam has been rejected. Please retry onboarding.",
|
||||
"learning.proctoringPanel.message.error": "An error has occurred during your onboarding exam. Please retry onboarding.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "You are eligible to take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "Your onboarding exam has been approved in another course.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "If your device has changed, we recommend that you complete this course's onboarding exam in order to ensure that your setup still meets the requirements for proctoring.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.generalInfo": "You must complete the onboarding process prior to taking any proctored exam. ",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "Your submitted profile is in review.",
|
||||
"learning.proctoringPanel.generalTime": "Onboarding profile review can take 2+ business days.",
|
||||
@@ -101,15 +101,76 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.outline.sequence-due": "{description} في{assignmentDue}",
|
||||
"learning.outline.alert.upgradecard.verifiedCertLink": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"learning.outline.alert.upgradecard.nonProfitMission": "Support our {nonProfitMission} at edX",
|
||||
"learning.outline.alert.upgradecard.unlock-graded": "Unlock your access to all course activities, including {gradedAssignments}",
|
||||
"learning.outline.alert.upgradecard.fullAccess": "{fullAccess} to course content and materials, even after the course ends",
|
||||
"learning.outline.alert.upgradecard.expirationAccessLoss": "You will lose all access to this course, {includingAnyProgress}, on {date}.",
|
||||
"learning.outline.alert.upgradecard.expirationVerifiedCert": "Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}.",
|
||||
"learning.outline.alert.upgradecard.expiration.days": "{dayCount, number} {dayCount, plural, \n one {day}\n other {days}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.hours": "{hourCount, number} {hourCount, plural,\n one {hour}\n other {hours}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.minutes": "Less than 1 hour left",
|
||||
"learning.outline.alert.upgradecard.expirationr": "Course access will expire {date}",
|
||||
"learning.outline.alert.upgradecard.code": "Use code {code} at checkout",
|
||||
"learning.outline.alert.upgradecard.firstTimeLearnerDiscount": "{percentage}% First-Time Learner Discount",
|
||||
"learning.outline.alert.upgradecard.accessExpiration": "Upgrade your course today",
|
||||
"learning.outline.alert.upgradecard.accessExpirationUrgent": "Course Access Expiration",
|
||||
"learning.outline.alert.upgradecard.pursueAverifiedCertificate": "Pursue a verified certificate",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "بعد انتهاء هذا المساق رسميًا في {endDate}، ستحصل على \nإشعار عبر البريد الإلكتروني بشهادتك. بعد أن تحصل على الشهادة، تأكد من\nعرض إنجازاتك على LinkedIn أو سيرتك الذاتية.",
|
||||
"progress.certificateStatus.notPassingHeader": "Certificate status",
|
||||
"progress.certificateStatus.notPassingBody": "In order to qualify for a certificate, you must have a passing grade.",
|
||||
"progress.certificateStatus.inProgressHeader": "More content is coming soon!",
|
||||
"progress.certificateStatus.inProgressBody": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
"progress.certificateStatus.requestableHeader": "Certificate status",
|
||||
"progress.certificateStatus.requestableBody": "Congratulations, you qualified for a certificate! In order to access your certificate, request it below.",
|
||||
"progress.certificateStatus.requestableButton": "Request certificate",
|
||||
"progress.certificateStatus.unverifiedHeader": "Certificate status",
|
||||
"progress.certificateStatus.unverifiedButton": "Verify ID",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.",
|
||||
"progress.certificateStatus.downloadableHeader": "Your certificate is available!",
|
||||
"progress.certificateStatus.downloadableButton": "Download my certificate",
|
||||
"progress.certificateStatus.viewableButton": "View my certificate",
|
||||
"progress.certificateStatus.notAvailableHeader": "Certificate status",
|
||||
"progress.certificateStatus.notAvailableBody": "Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.",
|
||||
"progress.certificateStatus.upgradeHeader": "Earn a certificate",
|
||||
"progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.",
|
||||
"progress.certificateStatus.upgradeButton": "Upgrade now",
|
||||
"progress.completion.donut.label": "completed",
|
||||
"progress.completion.body": "This represents how much of the course content you have completed. Note that some content may not yet be released.",
|
||||
"progress.completion.tooltip.locked": "Content that you have completed.",
|
||||
"progress.completion.header": "Course completion",
|
||||
"progress.completion.tooltip": "Content that you have access to and have not completed.",
|
||||
"progress.completion.tooltip.complete": "Content that is locked and available only to those who upgrade.",
|
||||
"progress.completion.donut.percentComplete": "You have completed {percent}% of content in this course.",
|
||||
"progress.completion.donut.percentIncomplete": "You have not completed {percent}% of content in this course that you have access to.",
|
||||
"progress.completion.donut.percentLocked": "{percent}% of content in this course is locked and available only for those who upgrade.",
|
||||
"progress.ungradedAlert": "For progress on ungraded aspects of the course, view your {outlineLink}.",
|
||||
"progress.footnotes.droppableAssignments": "The lowest {numDroppable, plural, one{# {assignmentType} score} other{# {assignmentType} scores}} will be dropped.",
|
||||
"progress.assignmentType": "Assignment type",
|
||||
"progress.footnotes.backToContent": "Back to content",
|
||||
"progress.courseGrade.body": "This represents your weighted grade against the grade needed to pass this course.",
|
||||
"progress.courseGrade.gradeBar.altText": "Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.",
|
||||
"progress.courseGrade.footer.generic.passing": "You’re currently passing this course",
|
||||
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
|
||||
"progress.courseGrade.footer.passing": "You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)",
|
||||
"progress.courseGrade.preview.header": "locked feature",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "Preview of a ",
|
||||
"progress.courseGrade.preview.body": "Unlock to view grades and work towards a certificate",
|
||||
"progress.courseGrade.preview.button.upgrade": "Upgrade now",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Grade ranges for this course:",
|
||||
"progress.courseOutline": "Course Outline",
|
||||
"progress.courseGrade.label.currentGrade": "Your current grade",
|
||||
"progress.detailedGrades": "Detailed grades",
|
||||
"progress.detailedGrades.emptyTable": "You currently have no graded problem scores.",
|
||||
"progress.footnotes.title": "Grade summary footnotes",
|
||||
"progress.courseGrade.grades": "Grades",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Grade range tooltip",
|
||||
"progress.gradeSummary": "Grade summary",
|
||||
"progress.gradeSummary.tooltip": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.gradeSummary.tooltip.alt": "Grade summary tooltip",
|
||||
"progress.gradeSummary.tooltip.body": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.courseGrade.label.passingGrade": "Passing grade",
|
||||
"progress.score": "Score",
|
||||
"progress.weight": "Weight",
|
||||
"progress.weightedGrade": "Weighted grade",
|
||||
@@ -168,7 +229,6 @@
|
||||
"notes.button.hide": "إخفاء الملاحظات",
|
||||
"courseExit.catalogSearchSuggestion": "هل تطمح إلى تعلّم المزيد؟{searchOurCatalogLink} لاستكشاف المزيد من المساقات والبرامج.",
|
||||
"courseCelebration.certificateBody.available": "اعرض إنجازاتك على لينكد إن أو سيرتك الذاتية اليوم.\nيمكنك تنزيل الشهادة الآن والوصول إليها في أي وقت من\n{dashboardLink} و{profileLink}.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "بعد انتهاء هذا المساق رسميًا في {endDate}، ستحصل على \nإشعار عبر البريد الإلكتروني بشهادتك. بعد أن تحصل على الشهادة، تأكد من\nعرض إنجازاتك على LinkedIn أو سيرتك الذاتية.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "ستكون قادرًا على الوصول لشهادتك في أي وقت على \n {dashboardLink} و {profileLink}.",
|
||||
"courseCelebration.certificateBody.unverified": "لإنشاء شهادة يجب عليك إتمام عملية التحقق من الهوية.\n{idVerificationSupportLink} الآن.",
|
||||
"courseCelebration.certificateBody.upgradable": "لم يفت الأوان للترقية. بالنسبة لـ {price} ستقوم بإلغاء تأمين الوصول إلى كافة أنواع \nالواجبات في هذا المساق. عند الانتهاء، ستحصل على شهادة تم التحقق منها وهي إحدى\nالوثائق القيّمة لتحسين فرصك الوظيفية وتطويرك المهني، أو لتسليط الضوء على\nشهادة في التطبيقات التعليمية.",
|
||||
@@ -187,6 +247,7 @@
|
||||
"courseCelebration.certificateHeader.requestable": "تهانينا، لقد تأهلت للحصول على شهادة!",
|
||||
"courseCelebration.certificateHeader.upgradable": "قم بالترقية للحصول على شهادة معتمدة",
|
||||
"courseCelebration.certificateImage": "عينة الشهادة",
|
||||
"courseCelebration.completedCourseHeader": "You have completed your course.",
|
||||
"courseCelebration.congratulationsHeader": "تهانينا!",
|
||||
"courseCelebration.congratulationsImage": "يرفع أربعة أشخاص أيديهم احتفاءً",
|
||||
"courseExit.courseInProgressDescription": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
@@ -208,7 +269,7 @@
|
||||
"courseCelebration.requestCertificateBodyText": "لتصفح شهادتك ارفع الطلب أدناه.",
|
||||
"courseCelebration.requestCertificateButton": "طلب شهادة",
|
||||
"courseExit.searchOurCatalogLink": "ابحث في الدليل",
|
||||
"courseCelebration.shareHeader": "لقد أتممت مساقك، يرجى مشاركة نجاحك على قنوات التواصل الاجتماعي أو عبر البريد الإلكتروني.",
|
||||
"courseCelebration.shareMessage": "Share your success on social media or email.",
|
||||
"courseExit.social.shareCompletionMessage": "أتممت للتو {title} في {platform}!",
|
||||
"courseExit.upgradeButton": "ترقية الآن",
|
||||
"courseExit.upgradeLink": "الترقية الآن",
|
||||
@@ -230,9 +291,19 @@
|
||||
"learn.course.license.creativeCommons.terms.zero": "لا توجد عناصر",
|
||||
"learn.course.license.creativeCommons.text": "بعض الحقوق محفوظة",
|
||||
"learn.breadcrumb.navigation.course.home": "المساق ",
|
||||
"sidebar.notification.container": "Sidebar notification",
|
||||
"sidebar.open.button": "Show sidebar notification",
|
||||
"sidebar.close.button": "Close sidebar notification",
|
||||
"sidebar.responsive.close.button": "Back to course",
|
||||
"sidebar.notification.title": "Notifications",
|
||||
"sidebar.notification.no.message": "You have no new notifications at this time.",
|
||||
"learn.contentLock.content.locked": "محتوى مغلق",
|
||||
"learn.contentLock.complete.prerequisite": "يجب استيفاء المتطلبات الأساسية: '{priceqSectionName}' للوصول إلى هذا المحتوى.",
|
||||
"learn.contentLock.goToSection": "انتقل إلى قسم المتطلبات الأساسية",
|
||||
"gatedContent.paragraph.bulletOne": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"gatedContent.paragraph.bulletTwo": "Unlock access to all course activities, including {gradedAssignments}",
|
||||
"gatedContent.paragraph.bulletThree": "{fullAccess} to course content and materials, even after the course ends",
|
||||
"gatedContent.paragraph.bulletFour": "Support our {nonProfit} mission at edX",
|
||||
"learn.lockPaywall.title": "Graded assignments are locked",
|
||||
"learn.lockPaywall.content": "Upgrade to gain access to locked features like this one and get the most out of your course.",
|
||||
"learn.lockPaywall.upgrade.link": "Upgrade for {currencySymbol}{price}",
|
||||
@@ -278,7 +349,7 @@
|
||||
"learn.course.tabs.navigation.overflow.menu": "المزيد...",
|
||||
"learning.offer.screenReaderPrices": "السعر الأصلي: {originalPrice}, سعر الخصم: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "السعر الأصلي: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "قم بالترقية ({pricing})",
|
||||
"learning.upgradeButton.buttonText": "Upgrade for {pricing}",
|
||||
"masquerade-widget.userName.error.generic": "حدث خطأ؛ يرجى المحاولة مرة أخرى.",
|
||||
"masquerade-widget.userName.input.placeholder": "اسم المستخدم أو البريد الإلكتروني",
|
||||
"masquerade-widget.userName.input.label": "عرف كهذا المستخدم",
|
||||
|
||||
@@ -87,12 +87,12 @@
|
||||
"learning.proctoringPanel.message.notStarted": "No has comenzado tu examen de integración.",
|
||||
"learning.proctoringPanel.message.started": "Has comenzado tu examen de integración.",
|
||||
"learning.proctoringPanel.message.submitted": "Has enviado tu examen de integración.",
|
||||
"learning.proctoringPanel.message.verified": "Ahora puedes realizar exámenes supervisados en este curso.",
|
||||
"learning.proctoringPanel.message.verified": "Your onboarding exam has been approved in this course.",
|
||||
"learning.proctoringPanel.message.rejected": "Tu examen de integración ha sido rechazado. Vuelve a intentar la integración.",
|
||||
"learning.proctoringPanel.message.error": "Se ha producido un error durante tu examen de integración. Vuelve a intentar la integración.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "You are eligible to take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "Your onboarding exam has been approved in another course.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "If your device has changed, we recommend that you complete this course's onboarding exam in order to ensure that your setup still meets the requirements for proctoring.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Tu perfil de incorporación se ha aprobado en otro curso, por lo que puedes realizar exámenes supervisados en este curso. Sin embargo, tu estado de integración expirará pronto. Vuelve a completar el proceso de integración para asegurarte de que podrás seguir realizando exámenes supervisados.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.generalInfo": "Debes completar el proceso de integración antes de realizar cualquier examen supervisado. ",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "Tu perfil enviado está en revisión.",
|
||||
"learning.proctoringPanel.generalTime": "Onboarding profile review can take 2+ business days.",
|
||||
@@ -101,15 +101,76 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Apertura de la integración: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Revisar las instrucciones y los requisitos del sistema",
|
||||
"learning.outline.sequence-due": "Fecha límite para {description}: {assignmentDue}",
|
||||
"learning.outline.alert.upgradecard.verifiedCertLink": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"learning.outline.alert.upgradecard.nonProfitMission": "Support our {nonProfitMission} at edX",
|
||||
"learning.outline.alert.upgradecard.unlock-graded": "Unlock your access to all course activities, including {gradedAssignments}",
|
||||
"learning.outline.alert.upgradecard.fullAccess": "{fullAccess} al contenido y los materiales del curso, incluso después de que finalice el curso",
|
||||
"learning.outline.alert.upgradecard.expirationAccessLoss": "You will lose all access to this course, {includingAnyProgress}, on {date}.",
|
||||
"learning.outline.alert.upgradecard.expirationVerifiedCert": "Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}.",
|
||||
"learning.outline.alert.upgradecard.expiration.days": "{dayCount, number} {dayCount, plural, \n one {day}\n other {days}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.hours": "{hourCount, number} {hourCount, plural,\n one {hour}\n other {hours}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.minutes": "Queda menos de 1 hora",
|
||||
"learning.outline.alert.upgradecard.expirationr": "Course access will expire {date}",
|
||||
"learning.outline.alert.upgradecard.code": "Usa el código {code} al finalizar la compra",
|
||||
"learning.outline.alert.upgradecard.firstTimeLearnerDiscount": "{percentage}% de descuento de bienvenida para estudiantes nuevos",
|
||||
"learning.outline.alert.upgradecard.accessExpiration": "Cámbiate a la opción verificada",
|
||||
"learning.outline.alert.upgradecard.accessExpirationUrgent": "Vencimiento del acceso al curso",
|
||||
"learning.outline.alert.upgradecard.pursueAverifiedCertificate": "Pursue a verified certificate",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Después de que este curso termine oficialmente el {endDate}, recibirás una\n notificación por correo electrónico con tu certificado. Una vez que tengas tu certificado, asegúrate\n de mostrar tu logro en LinkedIn o en tu currículum.",
|
||||
"progress.certificateStatus.notPassingHeader": "Certificate status",
|
||||
"progress.certificateStatus.notPassingBody": "In order to qualify for a certificate, you must have a passing grade.",
|
||||
"progress.certificateStatus.inProgressHeader": "More content is coming soon!",
|
||||
"progress.certificateStatus.inProgressBody": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
"progress.certificateStatus.requestableHeader": "Certificate status",
|
||||
"progress.certificateStatus.requestableBody": "Congratulations, you qualified for a certificate! In order to access your certificate, request it below.",
|
||||
"progress.certificateStatus.requestableButton": "Request certificate",
|
||||
"progress.certificateStatus.unverifiedHeader": "Certificate status",
|
||||
"progress.certificateStatus.unverifiedButton": "Verify ID",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.",
|
||||
"progress.certificateStatus.downloadableHeader": "Your certificate is available!",
|
||||
"progress.certificateStatus.downloadableButton": "Download my certificate",
|
||||
"progress.certificateStatus.viewableButton": "View my certificate",
|
||||
"progress.certificateStatus.notAvailableHeader": "Certificate status",
|
||||
"progress.certificateStatus.notAvailableBody": "Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.",
|
||||
"progress.certificateStatus.upgradeHeader": "Earn a certificate",
|
||||
"progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.",
|
||||
"progress.certificateStatus.upgradeButton": "Upgrade now",
|
||||
"progress.completion.donut.label": "completed",
|
||||
"progress.completion.body": "This represents how much of the course content you have completed. Note that some content may not yet be released.",
|
||||
"progress.completion.tooltip.locked": "Content that you have completed.",
|
||||
"progress.completion.header": "Course completion",
|
||||
"progress.completion.tooltip": "Content that you have access to and have not completed.",
|
||||
"progress.completion.tooltip.complete": "Content that is locked and available only to those who upgrade.",
|
||||
"progress.completion.donut.percentComplete": "You have completed {percent}% of content in this course.",
|
||||
"progress.completion.donut.percentIncomplete": "You have not completed {percent}% of content in this course that you have access to.",
|
||||
"progress.completion.donut.percentLocked": "{percent}% of content in this course is locked and available only for those who upgrade.",
|
||||
"progress.ungradedAlert": "For progress on ungraded aspects of the course, view your {outlineLink}.",
|
||||
"progress.footnotes.droppableAssignments": "The lowest {numDroppable, plural, one{# {assignmentType} score} other{# {assignmentType} scores}} will be dropped.",
|
||||
"progress.assignmentType": "Assignment type",
|
||||
"progress.footnotes.backToContent": "Back to content",
|
||||
"progress.courseGrade.body": "This represents your weighted grade against the grade needed to pass this course.",
|
||||
"progress.courseGrade.gradeBar.altText": "Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.",
|
||||
"progress.courseGrade.footer.generic.passing": "You’re currently passing this course",
|
||||
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
|
||||
"progress.courseGrade.footer.passing": "You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)",
|
||||
"progress.courseGrade.preview.header": "locked feature",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "Preview of a ",
|
||||
"progress.courseGrade.preview.body": "Unlock to view grades and work towards a certificate",
|
||||
"progress.courseGrade.preview.button.upgrade": "Upgrade now",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Grade ranges for this course:",
|
||||
"progress.courseOutline": "Course Outline",
|
||||
"progress.courseGrade.label.currentGrade": "Your current grade",
|
||||
"progress.detailedGrades": "Detailed grades",
|
||||
"progress.detailedGrades.emptyTable": "You currently have no graded problem scores.",
|
||||
"progress.footnotes.title": "Grade summary footnotes",
|
||||
"progress.courseGrade.grades": "Grades",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Grade range tooltip",
|
||||
"progress.gradeSummary": "Grade summary",
|
||||
"progress.gradeSummary.tooltip": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.gradeSummary.tooltip.alt": "Grade summary tooltip",
|
||||
"progress.gradeSummary.tooltip.body": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.courseGrade.label.passingGrade": "Passing grade",
|
||||
"progress.score": "Score",
|
||||
"progress.weight": "Weight",
|
||||
"progress.weightedGrade": "Weighted grade",
|
||||
@@ -168,7 +229,6 @@
|
||||
"notes.button.hide": "Ocultar Notas",
|
||||
"courseExit.catalogSearchSuggestion": "¿Quieres saber más? {searchOurCatalogLink} para buscar más cursos y programas por explorar.",
|
||||
"courseCelebration.certificateBody.available": "\n Muestra tu logro en LinkedIn o en tu currículum hoy mismo.\n Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu\n {dashboardLink} y {profileLink}.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Después de que este curso termine oficialmente el {endDate}, recibirás una\n notificación por correo electrónico con tu certificado. Una vez que tengas tu certificado, asegúrate\n de mostrar tu logro en LinkedIn o en tu currículum.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "Podrás acceder a tu certificado en cualquier momento desde tu\n {dashboardLink} y {profileLink}.",
|
||||
"courseCelebration.certificateBody.unverified": "Para generar un certificado, debes completar la verificación de ID.\n {idVerificationSupportLink} ahora.",
|
||||
"courseCelebration.certificateBody.upgradable": "No es demasiado tarde para mejorar de categoría. Por {price}, obtendrás acceso a todas las asignaciones\n calificadas de este curso. Al terminar, recibirás un certificado verificado que es una\n valiosa credencial para mejorar tus perspectivas de trabajo y avanzar en tu carrera, o puedes usar dicho\n certificado para destacarlo en solicitudes universitarias.",
|
||||
@@ -187,6 +247,7 @@
|
||||
"courseCelebration.certificateHeader.requestable": "Congratulations, you qualified for a certificate!",
|
||||
"courseCelebration.certificateHeader.upgradable": "Mejora de categoría para obtener un certificado verificado",
|
||||
"courseCelebration.certificateImage": "Modelo de certificado",
|
||||
"courseCelebration.completedCourseHeader": "You have completed your course.",
|
||||
"courseCelebration.congratulationsHeader": "Congratulations!",
|
||||
"courseCelebration.congratulationsImage": "Cuatro personas levantando las manos en señal de celebración",
|
||||
"courseExit.courseInProgressDescription": "Parece que hay más contenido en este curso que se publicará en el futuro. Presta atención a las novedades por correo electrónico o consulta tu curso para saber cuándo estará disponible este contenido.",
|
||||
@@ -208,7 +269,7 @@
|
||||
"courseCelebration.requestCertificateBodyText": "Para acceder a tu certificado, solicítalo a continuación.",
|
||||
"courseCelebration.requestCertificateButton": "Solicitar certificado",
|
||||
"courseExit.searchOurCatalogLink": "Search our catalog",
|
||||
"courseCelebration.shareHeader": "Has completado tu curso. Comparte tu éxito en las redes sociales o por correo electrónico.",
|
||||
"courseCelebration.shareMessage": "Share your success on social media or email.",
|
||||
"courseExit.social.shareCompletionMessage": "¡Acabo de completar {title} con {plataform}!",
|
||||
"courseExit.upgradeButton": "Upgrade now",
|
||||
"courseExit.upgradeLink": "mejora de categoría ahora",
|
||||
@@ -230,18 +291,28 @@
|
||||
"learn.course.license.creativeCommons.terms.zero": "Sin condiciones",
|
||||
"learn.course.license.creativeCommons.text": "Algunos Derechos Reservados",
|
||||
"learn.breadcrumb.navigation.course.home": "Curso",
|
||||
"sidebar.notification.container": "Notificación de la barra lateral",
|
||||
"sidebar.open.button": "Mostrar la notificación de la barra lateral",
|
||||
"sidebar.close.button": "Cerrar la notificación de la barra lateral",
|
||||
"sidebar.responsive.close.button": "Regresar al curso",
|
||||
"sidebar.notification.title": "Notificaciones",
|
||||
"sidebar.notification.no.message": "No tienes notificaciones nuevas en este momento.",
|
||||
"learn.contentLock.content.locked": "Contenido Bloqueado",
|
||||
"learn.contentLock.complete.prerequisite": "Debe completar el prerrequisito: '{prereqSectionName}'para acceder a este contenido.",
|
||||
"learn.contentLock.goToSection": "Ir a la Sección de Prerrequisitos",
|
||||
"learn.lockPaywall.title": "Graded assignments are locked",
|
||||
"learn.lockPaywall.content": "Upgrade to gain access to locked features like this one and get the most out of your course.",
|
||||
"learn.lockPaywall.upgrade.link": "Upgrade for {currencySymbol}{price}",
|
||||
"gatedContent.paragraph.bulletOne": "Obtén un {verifiedCertLink} de finalización para compartirlo en tu currículum",
|
||||
"gatedContent.paragraph.bulletTwo": "Desbloquea el acceso a todas las actividades del curso, incluidas las {gradedAssignments}",
|
||||
"gatedContent.paragraph.bulletThree": "{fullAccess} al contenido y los materiales del curso, incluso después de que finalice el curso",
|
||||
"gatedContent.paragraph.bulletFour": "Apoya nuestra {nonProfit} en edX",
|
||||
"learn.lockPaywall.title": "Las tareas calificadas están bloqueadas",
|
||||
"learn.lockPaywall.content": "Cámbiate a la opción verificada para obtener acceso a funciones bloqueadas como esta y aprovechar al máximo tu curso.",
|
||||
"learn.lockPaywall.upgrade.link": "Opción verificada {currencySymbol}{price}",
|
||||
"learn.lockPaywall.example.alt": "Certificado de ejemplo",
|
||||
"learn.lockPaywall.list.intro": "When you upgrade, you:",
|
||||
"learn.lockPaywall.list.bullet1.linktext": "verified certificate",
|
||||
"learn.lockPaywall.list.bullet2.boldtext": "graded assignments",
|
||||
"learn.lockPaywall.list.bullet3.boldtext": "Full access",
|
||||
"learn.lockPaywall.list.bullet4.boldtext": "non-profit",
|
||||
"learn.lockPaywall.list.intro": "Cuando te cambias a la opción verificada, tú:",
|
||||
"learn.lockPaywall.list.bullet1.linktext": "certificado verificado",
|
||||
"learn.lockPaywall.list.bullet2.boldtext": "tareas calificadas",
|
||||
"learn.lockPaywall.list.bullet3.boldtext": "Acceso completo",
|
||||
"learn.lockPaywall.list.bullet4.boldtext": "misión sin fines de lucro",
|
||||
"learn.loading.content.lock": "Cargando la mensajería de contenido bloqueado...",
|
||||
"learn.loading.learning.sequence": "Cargando la secuencia de aprendizaje...",
|
||||
"learn.course.load.failure": "Hubo un error al cargar este curso.",
|
||||
@@ -278,7 +349,7 @@
|
||||
"learn.course.tabs.navigation.overflow.menu": "Más...",
|
||||
"learning.offer.screenReaderPrices": "Precio original: {originalPrice}; precio con descuento: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "Precio original: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "Mejora ({precio})",
|
||||
"learning.upgradeButton.buttonText": "Upgrade for {pricing}",
|
||||
"masquerade-widget.userName.error.generic": "Se ha producido un error. Inténtalo de nuevo.",
|
||||
"masquerade-widget.userName.input.placeholder": "Nombre de usuario o correo electrónico",
|
||||
"masquerade-widget.userName.input.label": "Hazte pasar por este usuario",
|
||||
|
||||
@@ -87,12 +87,12 @@
|
||||
"learning.proctoringPanel.message.notStarted": "You have not started your onboarding exam.",
|
||||
"learning.proctoringPanel.message.started": "You have started your onboarding exam.",
|
||||
"learning.proctoringPanel.message.submitted": "You have submitted your onboarding exam.",
|
||||
"learning.proctoringPanel.message.verified": "You can now take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.verified": "Your onboarding exam has been approved in this course.",
|
||||
"learning.proctoringPanel.message.rejected": "Your onboarding exam has been rejected. Please retry onboarding.",
|
||||
"learning.proctoringPanel.message.error": "An error has occurred during your onboarding exam. Please retry onboarding.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "You are eligible to take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "Your onboarding exam has been approved in another course.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "If your device has changed, we recommend that you complete this course's onboarding exam in order to ensure that your setup still meets the requirements for proctoring.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.generalInfo": "You must complete the onboarding process prior to taking any proctored exam. ",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "Your submitted profile is in review.",
|
||||
"learning.proctoringPanel.generalTime": "Onboarding profile review can take 2+ business days.",
|
||||
@@ -101,15 +101,76 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.alert.upgradecard.verifiedCertLink": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"learning.outline.alert.upgradecard.nonProfitMission": "Support our {nonProfitMission} at edX",
|
||||
"learning.outline.alert.upgradecard.unlock-graded": "Unlock your access to all course activities, including {gradedAssignments}",
|
||||
"learning.outline.alert.upgradecard.fullAccess": "{fullAccess} to course content and materials, even after the course ends",
|
||||
"learning.outline.alert.upgradecard.expirationAccessLoss": "You will lose all access to this course, {includingAnyProgress}, on {date}.",
|
||||
"learning.outline.alert.upgradecard.expirationVerifiedCert": "Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}.",
|
||||
"learning.outline.alert.upgradecard.expiration.days": "{dayCount, number} {dayCount, plural, \n one {day}\n other {days}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.hours": "{hourCount, number} {hourCount, plural,\n one {hour}\n other {hours}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.minutes": "Less than 1 hour left",
|
||||
"learning.outline.alert.upgradecard.expirationr": "Course access will expire {date}",
|
||||
"learning.outline.alert.upgradecard.code": "Use code {code} at checkout",
|
||||
"learning.outline.alert.upgradecard.firstTimeLearnerDiscount": "{percentage}% First-Time Learner Discount",
|
||||
"learning.outline.alert.upgradecard.accessExpiration": "Upgrade your course today",
|
||||
"learning.outline.alert.upgradecard.accessExpirationUrgent": "Course Access Expiration",
|
||||
"learning.outline.alert.upgradecard.pursueAverifiedCertificate": "Pursue a verified certificate",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "After this course officially ends on {endDate}, you will receive an\n email notification with your certificate. Once you have your certificate, be sure\n to showcase your accomplishment on LinkedIn or your resumé.",
|
||||
"progress.certificateStatus.notPassingHeader": "Certificate status",
|
||||
"progress.certificateStatus.notPassingBody": "In order to qualify for a certificate, you must have a passing grade.",
|
||||
"progress.certificateStatus.inProgressHeader": "More content is coming soon!",
|
||||
"progress.certificateStatus.inProgressBody": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
"progress.certificateStatus.requestableHeader": "Certificate status",
|
||||
"progress.certificateStatus.requestableBody": "Congratulations, you qualified for a certificate! In order to access your certificate, request it below.",
|
||||
"progress.certificateStatus.requestableButton": "Request certificate",
|
||||
"progress.certificateStatus.unverifiedHeader": "Certificate status",
|
||||
"progress.certificateStatus.unverifiedButton": "Verify ID",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.",
|
||||
"progress.certificateStatus.downloadableHeader": "Your certificate is available!",
|
||||
"progress.certificateStatus.downloadableButton": "Download my certificate",
|
||||
"progress.certificateStatus.viewableButton": "View my certificate",
|
||||
"progress.certificateStatus.notAvailableHeader": "Certificate status",
|
||||
"progress.certificateStatus.notAvailableBody": "Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.",
|
||||
"progress.certificateStatus.upgradeHeader": "Earn a certificate",
|
||||
"progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.",
|
||||
"progress.certificateStatus.upgradeButton": "Upgrade now",
|
||||
"progress.completion.donut.label": "completed",
|
||||
"progress.completion.body": "This represents how much of the course content you have completed. Note that some content may not yet be released.",
|
||||
"progress.completion.tooltip.locked": "Content that you have completed.",
|
||||
"progress.completion.header": "Course completion",
|
||||
"progress.completion.tooltip": "Content that you have access to and have not completed.",
|
||||
"progress.completion.tooltip.complete": "Content that is locked and available only to those who upgrade.",
|
||||
"progress.completion.donut.percentComplete": "You have completed {percent}% of content in this course.",
|
||||
"progress.completion.donut.percentIncomplete": "You have not completed {percent}% of content in this course that you have access to.",
|
||||
"progress.completion.donut.percentLocked": "{percent}% of content in this course is locked and available only for those who upgrade.",
|
||||
"progress.ungradedAlert": "For progress on ungraded aspects of the course, view your {outlineLink}.",
|
||||
"progress.footnotes.droppableAssignments": "The lowest {numDroppable, plural, one{# {assignmentType} score} other{# {assignmentType} scores}} will be dropped.",
|
||||
"progress.assignmentType": "Assignment type",
|
||||
"progress.footnotes.backToContent": "Back to content",
|
||||
"progress.courseGrade.body": "This represents your weighted grade against the grade needed to pass this course.",
|
||||
"progress.courseGrade.gradeBar.altText": "Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.",
|
||||
"progress.courseGrade.footer.generic.passing": "You’re currently passing this course",
|
||||
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
|
||||
"progress.courseGrade.footer.passing": "You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)",
|
||||
"progress.courseGrade.preview.header": "locked feature",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "Preview of a ",
|
||||
"progress.courseGrade.preview.body": "Unlock to view grades and work towards a certificate",
|
||||
"progress.courseGrade.preview.button.upgrade": "Upgrade now",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Grade ranges for this course:",
|
||||
"progress.courseOutline": "Course Outline",
|
||||
"progress.courseGrade.label.currentGrade": "Your current grade",
|
||||
"progress.detailedGrades": "Detailed grades",
|
||||
"progress.detailedGrades.emptyTable": "You currently have no graded problem scores.",
|
||||
"progress.footnotes.title": "Grade summary footnotes",
|
||||
"progress.courseGrade.grades": "Grades",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Grade range tooltip",
|
||||
"progress.gradeSummary": "Grade summary",
|
||||
"progress.gradeSummary.tooltip": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.gradeSummary.tooltip.alt": "Grade summary tooltip",
|
||||
"progress.gradeSummary.tooltip.body": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.courseGrade.label.passingGrade": "Passing grade",
|
||||
"progress.score": "Score",
|
||||
"progress.weight": "Weight",
|
||||
"progress.weightedGrade": "Weighted grade",
|
||||
@@ -168,7 +229,6 @@
|
||||
"notes.button.hide": "Hide Notes",
|
||||
"courseExit.catalogSearchSuggestion": "Looking to learn more? {searchOurCatalogLink} to find more courses and programs to explore.",
|
||||
"courseCelebration.certificateBody.available": "\n Showcase your accomplishment on LinkedIn or your resumé today.\n You can download your certificate now and access it any time from your\n {dashboardLink} and {profileLink}.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "After this course officially ends on {endDate}, you will receive an\n email notification with your certificate. Once you have your certificate, be sure\n to showcase your accomplishment on LinkedIn or your resumé.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "You will be able to access your certificate any time from your\n {dashboardLink} and {profileLink}.",
|
||||
"courseCelebration.certificateBody.unverified": "In order to generate a certificate, you must complete ID verification.\n {idVerificationSupportLink} now.",
|
||||
"courseCelebration.certificateBody.upgradable": "It’s not too late to upgrade. For {price} you will unlock access to all graded\n assignments in this course. Upon completion, you will receive a verified certificate which is a\n valuable credential to improve your job prospects and advance your career, or highlight your\n certificate in school applications.",
|
||||
@@ -187,6 +247,7 @@
|
||||
"courseCelebration.certificateHeader.requestable": "Congratulations, you qualified for a certificate!",
|
||||
"courseCelebration.certificateHeader.upgradable": "Upgrade to pursue a verified certificate",
|
||||
"courseCelebration.certificateImage": "Sample certificate",
|
||||
"courseCelebration.completedCourseHeader": "You have completed your course.",
|
||||
"courseCelebration.congratulationsHeader": "Congratulations!",
|
||||
"courseCelebration.congratulationsImage": "Four people raising their hands in celebration",
|
||||
"courseExit.courseInProgressDescription": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
@@ -208,7 +269,7 @@
|
||||
"courseCelebration.requestCertificateBodyText": "In order to access your certificate, request it below.",
|
||||
"courseCelebration.requestCertificateButton": "Request certificate",
|
||||
"courseExit.searchOurCatalogLink": "Search our catalog",
|
||||
"courseCelebration.shareHeader": "You have completed your course. Share your success on social media or email.",
|
||||
"courseCelebration.shareMessage": "Share your success on social media or email.",
|
||||
"courseExit.social.shareCompletionMessage": "I just completed {title} with {platform}!",
|
||||
"courseExit.upgradeButton": "Upgrade now",
|
||||
"courseExit.upgradeLink": "upgrade now",
|
||||
@@ -230,9 +291,19 @@
|
||||
"learn.course.license.creativeCommons.terms.zero": "No terms",
|
||||
"learn.course.license.creativeCommons.text": "Some Rights Reserved",
|
||||
"learn.breadcrumb.navigation.course.home": "Course",
|
||||
"sidebar.notification.container": "Sidebar notification",
|
||||
"sidebar.open.button": "Show sidebar notification",
|
||||
"sidebar.close.button": "Close sidebar notification",
|
||||
"sidebar.responsive.close.button": "Back to course",
|
||||
"sidebar.notification.title": "Notifications",
|
||||
"sidebar.notification.no.message": "You have no new notifications at this time.",
|
||||
"learn.contentLock.content.locked": "Content Locked",
|
||||
"learn.contentLock.complete.prerequisite": "You must complete the prerequisite: '{prereqSectionName}' to access this content.",
|
||||
"learn.contentLock.goToSection": "Go To Prerequisite Section",
|
||||
"gatedContent.paragraph.bulletOne": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"gatedContent.paragraph.bulletTwo": "Unlock access to all course activities, including {gradedAssignments}",
|
||||
"gatedContent.paragraph.bulletThree": "{fullAccess} to course content and materials, even after the course ends",
|
||||
"gatedContent.paragraph.bulletFour": "Support our {nonProfit} mission at edX",
|
||||
"learn.lockPaywall.title": "Graded assignments are locked",
|
||||
"learn.lockPaywall.content": "Upgrade to gain access to locked features like this one and get the most out of your course.",
|
||||
"learn.lockPaywall.upgrade.link": "Upgrade for {currencySymbol}{price}",
|
||||
@@ -278,7 +349,7 @@
|
||||
"learn.course.tabs.navigation.overflow.menu": "More...",
|
||||
"learning.offer.screenReaderPrices": "Original price: {originalPrice}, discount price: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "Original price: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "Upgrade ({pricing})",
|
||||
"learning.upgradeButton.buttonText": "Upgrade for {pricing}",
|
||||
"masquerade-widget.userName.error.generic": "An error has occurred; please try again.",
|
||||
"masquerade-widget.userName.input.placeholder": "Username or email",
|
||||
"masquerade-widget.userName.input.label": "Masquerade as this user",
|
||||
|
||||
@@ -87,12 +87,12 @@
|
||||
"learning.proctoringPanel.message.notStarted": "You have not started your onboarding exam.",
|
||||
"learning.proctoringPanel.message.started": "You have started your onboarding exam.",
|
||||
"learning.proctoringPanel.message.submitted": "You have submitted your onboarding exam.",
|
||||
"learning.proctoringPanel.message.verified": "You can now take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.verified": "Your onboarding exam has been approved in this course.",
|
||||
"learning.proctoringPanel.message.rejected": "Your onboarding exam has been rejected. Please retry onboarding.",
|
||||
"learning.proctoringPanel.message.error": "An error has occurred during your onboarding exam. Please retry onboarding.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "You are eligible to take proctored exams in this course.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "Your onboarding exam has been approved in another course.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "If your device has changed, we recommend that you complete this course's onboarding exam in order to ensure that your setup still meets the requirements for proctoring.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.",
|
||||
"learning.proctoringPanel.generalInfo": "You must complete the onboarding process prior to taking any proctored exam. ",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "Your submitted profile is in review.",
|
||||
"learning.proctoringPanel.generalTime": "Onboarding profile review can take 2+ business days.",
|
||||
@@ -101,15 +101,76 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.alert.upgradecard.verifiedCertLink": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"learning.outline.alert.upgradecard.nonProfitMission": "Support our {nonProfitMission} at edX",
|
||||
"learning.outline.alert.upgradecard.unlock-graded": "Unlock your access to all course activities, including {gradedAssignments}",
|
||||
"learning.outline.alert.upgradecard.fullAccess": "{fullAccess} to course content and materials, even after the course ends",
|
||||
"learning.outline.alert.upgradecard.expirationAccessLoss": "You will lose all access to this course, {includingAnyProgress}, on {date}.",
|
||||
"learning.outline.alert.upgradecard.expirationVerifiedCert": "Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}.",
|
||||
"learning.outline.alert.upgradecard.expiration.days": "{dayCount, number} {dayCount, plural, \n one {day}\n other {days}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.hours": "{hourCount, number} {hourCount, plural,\n one {hour}\n other {hours}} left",
|
||||
"learning.outline.alert.upgradecard.expiration.minutes": "Less than 1 hour left",
|
||||
"learning.outline.alert.upgradecard.expirationr": "Course access will expire {date}",
|
||||
"learning.outline.alert.upgradecard.code": "Use code {code} at checkout",
|
||||
"learning.outline.alert.upgradecard.firstTimeLearnerDiscount": "{percentage}% First-Time Learner Discount",
|
||||
"learning.outline.alert.upgradecard.accessExpiration": "Upgrade your course today",
|
||||
"learning.outline.alert.upgradecard.accessExpirationUrgent": "Course Access Expiration",
|
||||
"learning.outline.alert.upgradecard.pursueAverifiedCertificate": "Pursue a verified certificate",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "After this course officially ends on {endDate}, you will receive an\n email notification with your certificate. Once you have your certificate, be sure\n to showcase your accomplishment on LinkedIn or your resumé.",
|
||||
"progress.certificateStatus.notPassingHeader": "Certificate status",
|
||||
"progress.certificateStatus.notPassingBody": "In order to qualify for a certificate, you must have a passing grade.",
|
||||
"progress.certificateStatus.inProgressHeader": "More content is coming soon!",
|
||||
"progress.certificateStatus.inProgressBody": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
"progress.certificateStatus.requestableHeader": "Certificate status",
|
||||
"progress.certificateStatus.requestableBody": "Congratulations, you qualified for a certificate! In order to access your certificate, request it below.",
|
||||
"progress.certificateStatus.requestableButton": "Request certificate",
|
||||
"progress.certificateStatus.unverifiedHeader": "Certificate status",
|
||||
"progress.certificateStatus.unverifiedButton": "Verify ID",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.",
|
||||
"progress.certificateStatus.downloadableHeader": "Your certificate is available!",
|
||||
"progress.certificateStatus.downloadableButton": "Download my certificate",
|
||||
"progress.certificateStatus.viewableButton": "View my certificate",
|
||||
"progress.certificateStatus.notAvailableHeader": "Certificate status",
|
||||
"progress.certificateStatus.notAvailableBody": "Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.",
|
||||
"progress.certificateStatus.upgradeHeader": "Earn a certificate",
|
||||
"progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.",
|
||||
"progress.certificateStatus.upgradeButton": "Upgrade now",
|
||||
"progress.completion.donut.label": "completed",
|
||||
"progress.completion.body": "This represents how much of the course content you have completed. Note that some content may not yet be released.",
|
||||
"progress.completion.tooltip.locked": "Content that you have completed.",
|
||||
"progress.completion.header": "Course completion",
|
||||
"progress.completion.tooltip": "Content that you have access to and have not completed.",
|
||||
"progress.completion.tooltip.complete": "Content that is locked and available only to those who upgrade.",
|
||||
"progress.completion.donut.percentComplete": "You have completed {percent}% of content in this course.",
|
||||
"progress.completion.donut.percentIncomplete": "You have not completed {percent}% of content in this course that you have access to.",
|
||||
"progress.completion.donut.percentLocked": "{percent}% of content in this course is locked and available only for those who upgrade.",
|
||||
"progress.ungradedAlert": "For progress on ungraded aspects of the course, view your {outlineLink}.",
|
||||
"progress.footnotes.droppableAssignments": "The lowest {numDroppable, plural, one{# {assignmentType} score} other{# {assignmentType} scores}} will be dropped.",
|
||||
"progress.assignmentType": "Assignment type",
|
||||
"progress.footnotes.backToContent": "Back to content",
|
||||
"progress.courseGrade.body": "This represents your weighted grade against the grade needed to pass this course.",
|
||||
"progress.courseGrade.gradeBar.altText": "Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.",
|
||||
"progress.courseGrade.footer.generic.passing": "You’re currently passing this course",
|
||||
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
|
||||
"progress.courseGrade.footer.passing": "You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)",
|
||||
"progress.courseGrade.preview.header": "locked feature",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "Preview of a ",
|
||||
"progress.courseGrade.preview.body": "Unlock to view grades and work towards a certificate",
|
||||
"progress.courseGrade.preview.button.upgrade": "Upgrade now",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Grade ranges for this course:",
|
||||
"progress.courseOutline": "Course Outline",
|
||||
"progress.courseGrade.label.currentGrade": "Your current grade",
|
||||
"progress.detailedGrades": "Detailed grades",
|
||||
"progress.detailedGrades.emptyTable": "You currently have no graded problem scores.",
|
||||
"progress.footnotes.title": "Grade summary footnotes",
|
||||
"progress.courseGrade.grades": "Grades",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Grade range tooltip",
|
||||
"progress.gradeSummary": "Grade summary",
|
||||
"progress.gradeSummary.tooltip": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.gradeSummary.tooltip.alt": "Grade summary tooltip",
|
||||
"progress.gradeSummary.tooltip.body": "Your course assignment's weight is determined by your instructor. By multiplying your score by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
"progress.courseGrade.label.passingGrade": "Passing grade",
|
||||
"progress.score": "Score",
|
||||
"progress.weight": "Weight",
|
||||
"progress.weightedGrade": "Weighted grade",
|
||||
@@ -168,7 +229,6 @@
|
||||
"notes.button.hide": "Hide Notes",
|
||||
"courseExit.catalogSearchSuggestion": "Looking to learn more? {searchOurCatalogLink} to find more courses and programs to explore.",
|
||||
"courseCelebration.certificateBody.available": "\n Showcase your accomplishment on LinkedIn or your resumé today.\n You can download your certificate now and access it any time from your\n {dashboardLink} and {profileLink}.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "After this course officially ends on {endDate}, you will receive an\n email notification with your certificate. Once you have your certificate, be sure\n to showcase your accomplishment on LinkedIn or your resumé.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "You will be able to access your certificate any time from your\n {dashboardLink} and {profileLink}.",
|
||||
"courseCelebration.certificateBody.unverified": "In order to generate a certificate, you must complete ID verification.\n {idVerificationSupportLink} now.",
|
||||
"courseCelebration.certificateBody.upgradable": "It’s not too late to upgrade. For {price} you will unlock access to all graded\n assignments in this course. Upon completion, you will receive a verified certificate which is a\n valuable credential to improve your job prospects and advance your career, or highlight your\n certificate in school applications.",
|
||||
@@ -187,6 +247,7 @@
|
||||
"courseCelebration.certificateHeader.requestable": "Congratulations, you qualified for a certificate!",
|
||||
"courseCelebration.certificateHeader.upgradable": "Upgrade to pursue a verified certificate",
|
||||
"courseCelebration.certificateImage": "Sample certificate",
|
||||
"courseCelebration.completedCourseHeader": "You have completed your course.",
|
||||
"courseCelebration.congratulationsHeader": "Congratulations!",
|
||||
"courseCelebration.congratulationsImage": "Four people raising their hands in celebration",
|
||||
"courseExit.courseInProgressDescription": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
@@ -208,7 +269,7 @@
|
||||
"courseCelebration.requestCertificateBodyText": "In order to access your certificate, request it below.",
|
||||
"courseCelebration.requestCertificateButton": "Request certificate",
|
||||
"courseExit.searchOurCatalogLink": "Search our catalog",
|
||||
"courseCelebration.shareHeader": "You have completed your course. Share your success on social media or email.",
|
||||
"courseCelebration.shareMessage": "Share your success on social media or email.",
|
||||
"courseExit.social.shareCompletionMessage": "I just completed {title} with {platform}!",
|
||||
"courseExit.upgradeButton": "Upgrade now",
|
||||
"courseExit.upgradeLink": "upgrade now",
|
||||
@@ -230,9 +291,19 @@
|
||||
"learn.course.license.creativeCommons.terms.zero": "No terms",
|
||||
"learn.course.license.creativeCommons.text": "Some Rights Reserved",
|
||||
"learn.breadcrumb.navigation.course.home": "Course",
|
||||
"sidebar.notification.container": "Sidebar notification",
|
||||
"sidebar.open.button": "Show sidebar notification",
|
||||
"sidebar.close.button": "Close sidebar notification",
|
||||
"sidebar.responsive.close.button": "Back to course",
|
||||
"sidebar.notification.title": "Notifications",
|
||||
"sidebar.notification.no.message": "You have no new notifications at this time.",
|
||||
"learn.contentLock.content.locked": "Content Locked",
|
||||
"learn.contentLock.complete.prerequisite": "You must complete the prerequisite: '{prereqSectionName}' to access this content.",
|
||||
"learn.contentLock.goToSection": "Go To Prerequisite Section",
|
||||
"gatedContent.paragraph.bulletOne": "Earn a {verifiedCertLink} of completion to showcase on your resume",
|
||||
"gatedContent.paragraph.bulletTwo": "Unlock access to all course activities, including {gradedAssignments}",
|
||||
"gatedContent.paragraph.bulletThree": "{fullAccess} to course content and materials, even after the course ends",
|
||||
"gatedContent.paragraph.bulletFour": "Support our {nonProfit} mission at edX",
|
||||
"learn.lockPaywall.title": "Graded assignments are locked",
|
||||
"learn.lockPaywall.content": "Upgrade to gain access to locked features like this one and get the most out of your course.",
|
||||
"learn.lockPaywall.upgrade.link": "Upgrade for {currencySymbol}{price}",
|
||||
@@ -278,7 +349,7 @@
|
||||
"learn.course.tabs.navigation.overflow.menu": "More...",
|
||||
"learning.offer.screenReaderPrices": "Original price: {originalPrice}, discount price: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "Original price: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "Upgrade ({pricing})",
|
||||
"learning.upgradeButton.buttonText": "Upgrade for {pricing}",
|
||||
"masquerade-widget.userName.error.generic": "An error has occurred; please try again.",
|
||||
"masquerade-widget.userName.input.placeholder": "Username or email",
|
||||
"masquerade-widget.userName.input.label": "Masquerade as this user",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
// TODO: Fix .container-fluid for mobile in paragon
|
||||
.container-fluid {
|
||||
max-width: 1140px + 2 * $grid-gutter-width;
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
padding-left: $grid-gutter-width/2;
|
||||
padding-right: $grid-gutter-width/2;
|
||||
@@ -245,6 +246,7 @@
|
||||
}
|
||||
|
||||
.previous-btn, .next-btn {
|
||||
border: 1px solid $light-400 !important;
|
||||
color: $gray-700;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
@@ -253,10 +255,6 @@
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
span {
|
||||
@include sr-only();
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
@@ -359,8 +357,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.locked-overlay {
|
||||
opacity: .3;
|
||||
pointer-events: none;
|
||||
|
||||
&.grades {
|
||||
overflow: hidden;
|
||||
max-height: 852px;
|
||||
@media screen and (min-width: 992px) {
|
||||
max-height: 920px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import component-specific sass files
|
||||
@import 'courseware/course/celebration/CelebrationModal.scss';
|
||||
@import 'courseware/course/Sidebar.scss';
|
||||
@import 'courseware/course/SidebarNotificationButton.scss';
|
||||
@import 'courseware/course/NotificationIcon.scss';
|
||||
@import 'shared/streak-celebration/StreakCelebrationModal.scss';
|
||||
@import 'courseware/course/content-tools/calculator/calculator.scss';
|
||||
@import 'courseware/course/content-tools/contentTools.scss';
|
||||
@@ -368,6 +382,8 @@
|
||||
@import 'course-home/dates-tab/Day.scss';
|
||||
@import 'course-home/outline-tab/widgets/UpgradeCard.scss';
|
||||
@import 'course-home/outline-tab/widgets/ProctoringInfoPanel.scss';
|
||||
@import 'course-home/progress-tab/course-completion/CompletionDonutChart.scss';
|
||||
@import 'course-home/progress-tab/grades/course-grade/GradeBar.scss';
|
||||
@import 'courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
|
||||
@@ -53,9 +53,14 @@ export default function InstructorToolbar(props) {
|
||||
const {
|
||||
courseId,
|
||||
unitId,
|
||||
canViewLegacyCourseware,
|
||||
} = props;
|
||||
const urlInsights = getInsightsUrl(courseId);
|
||||
const urlLegacy = useSelector((state) => {
|
||||
if (!canViewLegacyCourseware) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!unitId) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -113,9 +118,11 @@ export default function InstructorToolbar(props) {
|
||||
InstructorToolbar.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
canViewLegacyCourseware: PropTypes.bool,
|
||||
};
|
||||
|
||||
InstructorToolbar.defaultProps = {
|
||||
courseId: undefined,
|
||||
unitId: undefined,
|
||||
canViewLegacyCourseware: undefined,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('Instructor Toolbar', () => {
|
||||
mockData = {
|
||||
courseId: courseware.courseId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
canViewLegacyCourseware: true,
|
||||
};
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
@@ -53,7 +54,7 @@ describe('Instructor Toolbar', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Unable to get masquerade options');
|
||||
});
|
||||
|
||||
it('displays links to view course in different services', () => {
|
||||
it('displays links to view course in available services', () => {
|
||||
const config = { ...originalConfig };
|
||||
config.INSIGHTS_BASE_URL = 'http://localhost:18100';
|
||||
getConfig.mockImplementation(() => config);
|
||||
@@ -65,6 +66,32 @@ describe('Instructor Toolbar', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('displays links to view course in available services - false legacy courseware flag', () => {
|
||||
const config = { ...originalConfig };
|
||||
config.INSIGHTS_BASE_URL = 'http://localhost:18100';
|
||||
getConfig.mockImplementation(() => config);
|
||||
mockData.canViewLegacyCourseware = false;
|
||||
render(<InstructorToolbar {...mockData} />);
|
||||
|
||||
const linksContainer = screen.getByText('View course in:').parentElement;
|
||||
['Studio', 'Insights'].forEach(service => {
|
||||
expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays links to view course in available services - empty unit', () => {
|
||||
const config = { ...originalConfig };
|
||||
config.INSIGHTS_BASE_URL = 'http://localhost:18100';
|
||||
getConfig.mockImplementation(() => config);
|
||||
mockData.unitId = undefined;
|
||||
render(<InstructorToolbar {...mockData} />);
|
||||
|
||||
const linksContainer = screen.getByText('View course in:').parentElement;
|
||||
['Studio', 'Insights'].forEach(service => {
|
||||
expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display links if there are no services available', () => {
|
||||
const config = { ...originalConfig };
|
||||
config.STUDIO_BASE_URL = undefined;
|
||||
|
||||
@@ -36,20 +36,32 @@ window.getComputedStyle = jest.fn(() => ({
|
||||
getPropertyValue: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Intersection Observer which is unavailable in the context of a test.
|
||||
global.IntersectionObserver = jest.fn(function mockIntersectionObserver() {
|
||||
this.observe = jest.fn();
|
||||
this.disconnect = jest.fn();
|
||||
});
|
||||
|
||||
// Mock media queries because any component that uses `react-break` for responsive breakpoints will
|
||||
// run into `TypeError: window.matchMedia is not a function`. This avoids that for all of our tests now.
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
value: jest.fn().mockImplementation(query => {
|
||||
// Returns true given a mediaQuery for a screen size greater than 768px (this exact query is what react-break sends)
|
||||
// Without this, if we hardcode `matches` to either true or false, either all or none of the breakpoints match,
|
||||
// respectively.
|
||||
const matches = !!(query === 'screen and (min-width: 768px)');
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const authenticatedUser = {
|
||||
@@ -70,6 +82,7 @@ export function initializeMockApp() {
|
||||
roles: [],
|
||||
administrator: false,
|
||||
},
|
||||
SUPPORT_URL_ID_VERIFICATION: true,
|
||||
});
|
||||
|
||||
const loggingService = configureLogging(MockLoggingService, {
|
||||
|
||||
@@ -12,6 +12,11 @@ export default new Factory()
|
||||
original_user_is_staff: false,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
verifiedMode: {
|
||||
upgradeUrl: 'test',
|
||||
price: 10,
|
||||
currencySymbol: '$',
|
||||
},
|
||||
})
|
||||
.attr(
|
||||
'tabs', ['id', 'host'], (id, host) => {
|
||||
|
||||
66
src/shared/links.jsx
Normal file
66
src/shared/links.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import messages from '../courseware/course/course-exit/messages';
|
||||
|
||||
function IntlDashboardLink({ intl }) {
|
||||
return (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
>
|
||||
{intl.formatMessage(messages.dashboardLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
}
|
||||
|
||||
IntlDashboardLink.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
function IntlIdVerificationSupportLink({ intl }) {
|
||||
if (!getConfig().SUPPORT_URL_ID_VERIFICATION) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={getConfig().SUPPORT_URL_ID_VERIFICATION}
|
||||
>
|
||||
{intl.formatMessage(messages.idVerificationSupportLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
}
|
||||
|
||||
IntlIdVerificationSupportLink.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
function IntlProfileLink({ intl }) {
|
||||
const { username } = getAuthenticatedUser();
|
||||
|
||||
return (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
|
||||
>
|
||||
{intl.formatMessage(messages.profileLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
}
|
||||
|
||||
IntlProfileLink.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const DashboardLink = injectIntl(IntlDashboardLink);
|
||||
const IdVerificationSupportLink = injectIntl(IntlIdVerificationSupportLink);
|
||||
const ProfileLink = injectIntl(IntlProfileLink);
|
||||
|
||||
export { DashboardLink, IdVerificationSupportLink, ProfileLink };
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Lightbulb } from '@edx/paragon/icons';
|
||||
import { Icon, Modal } from '@edx/paragon';
|
||||
import {
|
||||
FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Lightbulb, MoneyFilled } from '@edx/paragon/icons';
|
||||
import {
|
||||
Alert, Icon, ModalDialog,
|
||||
} from '@edx/paragon';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { UpgradeNowButton } from '../../generic/upgrade-button';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import StreakMobileImage from './assets/Streak_mobile.png';
|
||||
@@ -37,8 +42,12 @@ function getRandomFactoid(intl, streakLength) {
|
||||
}
|
||||
|
||||
function StreakModal({
|
||||
courseId, metadataModel, streakLengthToCelebrate, intl, open, ...rest
|
||||
courseId, metadataModel, streakLengthToCelebrate, intl, isStreakCelebrationOpen,
|
||||
closeStreakCelebration, AA759ExperimentEnabled, verifiedMode, ...rest
|
||||
}) {
|
||||
if (!isStreakCelebrationOpen) {
|
||||
return null;
|
||||
}
|
||||
const { org, celebrations } = useModel(metadataModel, courseId);
|
||||
const factoid = getRandomFactoid(intl, streakLengthToCelebrate);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -54,10 +63,10 @@ function StreakModal({
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (isStreakCelebrationOpen) {
|
||||
recordStreakCelebration(org, courseId);
|
||||
}
|
||||
}, [open, org, courseId]);
|
||||
}, [isStreakCelebrationOpen, org, courseId]);
|
||||
|
||||
function CloseText() {
|
||||
return (
|
||||
@@ -68,43 +77,118 @@ function StreakModal({
|
||||
);
|
||||
}
|
||||
|
||||
let upgradeUrl;
|
||||
let mode;
|
||||
let offer;
|
||||
|
||||
if (verifiedMode) {
|
||||
upgradeUrl = `${verifiedMode.upgradeUrl}&code=3DayStreak`;
|
||||
mode = {
|
||||
currencySymbol: verifiedMode.currencySymbol,
|
||||
price: verifiedMode.price,
|
||||
upgradeUrl,
|
||||
};
|
||||
|
||||
offer = {
|
||||
discountedPrice: (mode.price * 0.85).toFixed(2).toString(),
|
||||
originalPrice: mode.price.toString(),
|
||||
upgradeUrl: mode.upgradeUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const title = `${streakLengthToCelebrate} ${intl.formatMessage(messages.streakHeader)}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
dialogClassName="streak-modal modal-dialog-centered"
|
||||
body={(
|
||||
<>
|
||||
<p>{intl.formatMessage(messages.streakBody)}</p>
|
||||
<p className="modal-image">
|
||||
<OnMobile>
|
||||
<img src={StreakMobileImage} alt="" className="img-fluid" />
|
||||
</OnMobile>
|
||||
<OnDesktop>
|
||||
<img src={StreakDesktopImage} alt="" className="img-fluid" />
|
||||
</OnDesktop>
|
||||
</p>
|
||||
<div className="row mt-3 mx-3 py-3 bg-light-300">
|
||||
<Icon className="col-small ml-3" src={Lightbulb} />
|
||||
<div className="col-11 factoid-wrapper">
|
||||
{randomFactoid}
|
||||
</div>
|
||||
<ModalDialog
|
||||
className="streak-modal modal-dialog-centered"
|
||||
title={title}
|
||||
onClose={() => {
|
||||
closeStreakCelebration();
|
||||
recordModalClosing(metadataModel, celebrations, org, courseId, dispatch);
|
||||
}}
|
||||
isOpen={isStreakCelebrationOpen}
|
||||
isFullscreenScroll
|
||||
{...rest}
|
||||
>
|
||||
<ModalDialog.Header className="modal-header">
|
||||
<ModalDialog.Title className="mr-0 modal-title">
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body className="modal-body">
|
||||
<p>{intl.formatMessage(messages.streakBody)}</p>
|
||||
<p className="modal-image">
|
||||
<OnMobile>
|
||||
<img src={StreakMobileImage} alt="" className="img-fluid" />
|
||||
</OnMobile>
|
||||
<OnDesktop>
|
||||
<img src={StreakDesktopImage} alt="" className="img-fluid" />
|
||||
</OnDesktop>
|
||||
</p>
|
||||
{ !AA759ExperimentEnabled && (
|
||||
<div className="d-flex py-3 bg-light-300">
|
||||
<Icon className="col-small ml-3" src={Lightbulb} />
|
||||
<div className="col-11 factoid-wrapper">
|
||||
{randomFactoid}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ AA759ExperimentEnabled && (
|
||||
<Alert variant="success" className="px-0 d-flex">
|
||||
<Icon className="col-small ml-3 text-success-500" src={MoneyFilled} />
|
||||
<div className="col-11 factoid-wrapper">
|
||||
<b>{intl.formatMessage(messages.congratulations)}</b>
|
||||
{intl.formatMessage(messages.streakDiscountMessage)}
|
||||
<FormattedMessage
|
||||
id="learning.streakCelebration.streakAA759EndDateMessage"
|
||||
defaultMessage="Ends {date}."
|
||||
values={{
|
||||
date: new Date('2021-6-25 00:00').toLocaleDateString({ timeZone: 'UTC' }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="modal-footer d-block">
|
||||
{ AA759ExperimentEnabled && (
|
||||
<>
|
||||
<OnMobile>
|
||||
<UpgradeNowButton
|
||||
className="upgrade mb-3"
|
||||
size="sm"
|
||||
offer={offer}
|
||||
variant="brand"
|
||||
verifiedMode={mode}
|
||||
/>
|
||||
<ModalDialog.CloseButton variant="outline-brand" className="btn-sm">
|
||||
{intl.formatMessage(messages.streakButtonAA759)}
|
||||
</ModalDialog.CloseButton>
|
||||
</OnMobile>
|
||||
<OnDesktop>
|
||||
<UpgradeNowButton
|
||||
className="upgrade mb-3"
|
||||
offer={offer}
|
||||
variant="brand"
|
||||
verifiedMode={mode}
|
||||
/>
|
||||
<ModalDialog.CloseButton variant="outline-brand">
|
||||
{intl.formatMessage(messages.streakButtonAA759)}
|
||||
</ModalDialog.CloseButton>
|
||||
</OnDesktop>
|
||||
</>
|
||||
)}
|
||||
closeText={<CloseText />}
|
||||
onClose={() => {
|
||||
recordModalClosing(metadataModel, celebrations, org, courseId, dispatch);
|
||||
}}
|
||||
open={open}
|
||||
title={`${streakLengthToCelebrate} ${intl.formatMessage(messages.streakHeader)}`}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
{ !AA759ExperimentEnabled && (
|
||||
<ModalDialog.CloseButton className="px-5" variant="primary"><CloseText /></ModalDialog.CloseButton>
|
||||
)}
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
|
||||
StreakModal.defaultProps = {
|
||||
open: false,
|
||||
isStreakCelebrationOpen: false,
|
||||
AA759ExperimentEnabled: false,
|
||||
};
|
||||
|
||||
StreakModal.propTypes = {
|
||||
@@ -112,7 +196,14 @@ StreakModal.propTypes = {
|
||||
metadataModel: PropTypes.string.isRequired,
|
||||
streakLengthToCelebrate: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
open: PropTypes.bool,
|
||||
isStreakCelebrationOpen: PropTypes.bool,
|
||||
closeStreakCelebration: PropTypes.func.isRequired,
|
||||
AA759ExperimentEnabled: PropTypes.bool,
|
||||
verifiedMode: PropTypes.shape({
|
||||
currencySymbol: PropTypes.string.isRequired,
|
||||
price: PropTypes.number.isRequired,
|
||||
upgradeUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(StreakModal);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
text-align: center;
|
||||
|
||||
.modal-header {
|
||||
padding-top: 1.875rem;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0; // override default hr line
|
||||
justify-content: center;
|
||||
@@ -19,6 +20,7 @@
|
||||
|
||||
.modal-title {
|
||||
padding-top: 1.25rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -29,12 +31,6 @@
|
||||
.modal-footer {
|
||||
border-top: 0; // override default hr line
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
@extend .btn-primary;
|
||||
font-size: 1.2rem;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
|
||||
@@ -10,18 +10,19 @@ describe('Loaded Tab Page', () => {
|
||||
const mockData = { metadataModel: 'coursewareMeta' };
|
||||
|
||||
beforeAll(async () => {
|
||||
mockData.open = true;
|
||||
mockData.isStreakCelebrationOpen = true;
|
||||
mockData.streakLengthToCelebrate = 3;
|
||||
});
|
||||
|
||||
it('shows streak celebration modal', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { shouldCelebrateStreak: true } });
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { streakLengthToCelebrate: 3 } });
|
||||
mockData.courseId = courseMetadata.id;
|
||||
mockData.verifiedMode = courseMetadata.verifiedMode;
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
render(<StreakModal {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
|
||||
await screen.findByText('3 day streak');
|
||||
await screen.findByText('Keep it up, you’re on a roll!');
|
||||
expect(screen.getByText('3 day streak')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep it up, you’re on a roll!')).toBeInTheDocument();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.celebration.streak.opened', {
|
||||
org_key: courseMetadata.org,
|
||||
@@ -29,4 +30,32 @@ describe('Loaded Tab Page', () => {
|
||||
is_staff: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows streak celebration modal AA-759 experiment', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => {
|
||||
const matches = !!(query === 'screen and (min-width: 575px)');
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { shouldCelebrateStreak: 3 } });
|
||||
mockData.courseId = courseMetadata.id;
|
||||
mockData.verifiedMode = courseMetadata.verifiedMode;
|
||||
mockData.AA759ExperimentEnabled = true;
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
render(<StreakModal {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
expect(screen.getByText('You’ve unlocked a 15% off discount when you upgrade this course for a limited time only.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ends 6/25/2021.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Continue with course')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
streakHeader: {
|
||||
id: 'learning.streakCelebration.header',
|
||||
defaultMessage: 'day streak',
|
||||
description: 'Will come after a number. For example, 3 day streak',
|
||||
congratulations: {
|
||||
id: 'learning.streakCelebration.congratulations',
|
||||
defaultMessage: 'Congratulations!',
|
||||
},
|
||||
streakBody: {
|
||||
id: 'learning.streakCelebration.body',
|
||||
@@ -19,6 +18,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Close modal and continue',
|
||||
description: 'Screenreader label for streakButton text',
|
||||
},
|
||||
streakButtonAA759: {
|
||||
id: 'learning.streakCelebration.buttonAA759',
|
||||
defaultMessage: 'Continue with course',
|
||||
},
|
||||
streakHeader: {
|
||||
id: 'learning.streakCelebration.header',
|
||||
defaultMessage: 'day streak',
|
||||
description: 'Will come after a number. For example, 3 day streak',
|
||||
},
|
||||
streakFactoidABoldedSection: {
|
||||
id: 'learning.streakCelebration.factoidABoldedSection',
|
||||
defaultMessage: 'are 20x more likely to pass their course',
|
||||
@@ -29,6 +37,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'complete 5x as much course content on average',
|
||||
description: 'This bolded section is in the following sentence: Users who learn 3 days in a row {bolded_section} vs. those who don\'t.',
|
||||
},
|
||||
streakDiscountMessage: {
|
||||
id: 'learning.streakCelebration.streakDiscountMessage',
|
||||
defaultMessage: 'You’ve unlocked a 15% off discount when you upgrade this course for a limited time only.',
|
||||
description: 'This message describes a discount the user becomes eligible for when they hit their three day streak',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useToggle } from '@edx/paragon';
|
||||
|
||||
import { Header, CourseTabsNavigation } from '../course-header';
|
||||
import { useModel } from '../generic/model-store';
|
||||
@@ -26,6 +27,8 @@ function LoadedTabPage({
|
||||
tabs,
|
||||
title,
|
||||
celebrations,
|
||||
canViewLegacyCourseware,
|
||||
verifiedMode,
|
||||
} = useModel(metadataModel, courseId);
|
||||
|
||||
// Logistration and enrollment alerts are only really used for the outline tab, but loaded here to put them above
|
||||
@@ -36,6 +39,8 @@ function LoadedTabPage({
|
||||
const activeTab = tabs.filter(tab => tab.slug === activeTabSlug)[0];
|
||||
|
||||
const streakLengthToCelebrate = celebrations && celebrations.streakLengthToCelebrate;
|
||||
const AA759ExperimentEnabled = celebrations && celebrations.streakDiscountExperimentEnabled && verifiedMode;
|
||||
const [isStreakCelebrationOpen,, closeStreakCelebration] = useToggle(streakLengthToCelebrate);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,16 +56,18 @@ function LoadedTabPage({
|
||||
<InstructorToolbar
|
||||
courseId={courseId}
|
||||
unitId={unitId}
|
||||
canViewLegacyCourseware={canViewLegacyCourseware}
|
||||
/>
|
||||
)}
|
||||
{streakLengthToCelebrate && (
|
||||
<StreakModal
|
||||
courseId={courseId}
|
||||
metadataModel={metadataModel}
|
||||
streakLengthToCelebrate={streakLengthToCelebrate}
|
||||
open
|
||||
/>
|
||||
)}
|
||||
<StreakModal
|
||||
courseId={courseId}
|
||||
metadataModel={metadataModel}
|
||||
streakLengthToCelebrate={streakLengthToCelebrate}
|
||||
isStreakCelebrationOpen={isStreakCelebrationOpen}
|
||||
closeStreakCelebration={closeStreakCelebration}
|
||||
AA759ExperimentEnabled={AA759ExperimentEnabled}
|
||||
verifiedMode={verifiedMode}
|
||||
/>
|
||||
<main id="main-content" className="d-flex flex-column flex-grow-1">
|
||||
<AlertList
|
||||
topic="outline"
|
||||
|
||||
Reference in New Issue
Block a user