Compare commits

...

43 Commits

Author SHA1 Message Date
Albert (AJ) St. Aubin
7680871ca2 PR 2021-05-21 12:57:28 -04:00
Albert (AJ) St. Aubin
013e7bd9a1 test 2021-05-20 20:17:21 -04:00
Albert (AJ) St. Aubin
059525c729 tests 2021-05-20 20:14:54 -04:00
Albert (AJ) St. Aubin
b9448d917a quality 2021-05-20 19:56:32 -04:00
Albert (AJ) St. Aubin
e18d6f0dae refactor: Updating the messages for certificate availability.
[MICROBA-678]
2021-05-20 16:27:34 -04:00
Matthew Piatetsky
6fa6de4543 fix: don't throw an error when there is no verified mode (#444)
AA-759
2021-05-11 16:16:41 -04:00
Matthew Piatetsky
d0bcb19754 feat: Set up streak celebration discount experiment (#431)
As part of this work, the streak celebration has been migrated from a Paragon Modal to a Modal Dialog
AA-759
2021-05-11 14:06:03 -04:00
Carla Duarte
0f69ed5502 AA-792: Progress tab UI fixes (#443) 2021-05-10 16:43:38 -04:00
edX Transifex Bot
039d761a27 fix(i18n): update translations 2021-05-10 02:07:36 +05:00
Diane Kaplan
e2dd081d44 fix checkmark bullets to vertically align with text (#440)
Co-authored-by: Diane Kaplan <dkaplan@edx.org>
2021-05-07 12:37:38 -04:00
Emma Green
7e75671618 add important so that it overrides other style 2021-05-07 10:48:23 -04:00
Emma Green
5ca10042c8 fix checkmark bullets to line up with text 2021-05-07 10:09:57 -04:00
alangsto
64979ecaf0 fix: Remove confusing language on proctoring info panel (#437)
MST-700. Remove language that says learners with a verified onboarding can take exams. This created confusion for learners who did not realize they must also complete IDV before taking a proctored exam.
2021-05-06 15:33:55 -04:00
Ben Holt
0175c4cf27 Guarded for undefined headers in getOutlineTabData 2021-05-06 12:32:16 -04:00
Emma Green
531f6d96ae more stuffs 2021-05-06 12:32:16 -04:00
Emma Green
997be712f1 add time offset 2021-05-06 12:32:16 -04:00
Emma Green
7e5dacf68d static expiration box 2021-05-06 12:32:16 -04:00
alangsto
73fa56d401 fix: update border color for approved in another course status (#436) 2021-05-06 10:25:56 -04:00
Michael Terry
e46977f50d fix: use new xblock handler URLs for sequence blocks (#423)
They've changed to proper new-style handlers, so the URL also
changed. This will let us get the fix for green checkmarks showing
up as audit users pass FBE units, even though those units aren't
actually complete.

AA-409
2021-05-06 09:38:57 -04:00
Awais Jibran
28e1f6f65a Fix height issue in safari (#434) 2021-05-05 23:10:42 +05:00
Julia Eskew
6c257271bb feat: Deny course staff visibility of legacy courseware. (#427)
Currently, course staff can always view their courses
in the Legacy courseware experience.

With this change, course staff will *not* be able
to view their courses if the New (MFE) courseware
experience has been enabled for them.

This does not affect global staff, and it does not
affect courses that are still running in the Legacy
experience.

Adds a new parameter returned by the course metadata API
used by the courseware MFE to determine if the button to
show legacy experience should be displayed or not.

TNL-8203
2021-05-04 13:57:00 -04:00
Carla Duarte
36f567c834 fix: accurately represent pass/fail grade range (#433) 2021-05-04 11:51:44 -04:00
Carla Duarte
c6627a0854 fix: remove sr-only scss mixin from unit nav buttons (#432) 2021-05-04 09:16:49 -04:00
Carla Duarte
608db6d423 AA-723: scss fixes (#430) 2021-05-03 14:25:56 -04:00
Carla Duarte
72168b56f8 AA-723: progress tab locked content experience (#426) 2021-05-03 13:11:22 -04:00
edX Transifex Bot
5af20067b8 fix(i18n): update translations 2021-05-03 20:05:51 +05:00
edX Transifex Bot
c8ae544c8b fix(i18n): update translations 2021-05-03 02:07:36 +05:00
julianajlk
28fddc5550 feat: Add Sidebar and SidebarNotificationButton components for Value Prop (#414)
REV-2125
2021-04-29 11:36:55 -04:00
Matthew Piatetsky
ef635b2a9b feat: Add certificate status component to the new progress page (#415)
Much of the logic is copied from the course exit certificate states.
AA-719
2021-04-29 09:39:07 -04:00
Matthew Piatetsky
ce69d57dc8 fix: add back events for course home and courseware sock upgrade button (#424)
AA-728
2021-04-28 12:20:08 -04:00
Dillon Dumesnil
43aa6291c3 fix: AA-663: Update header text for CourseCompletion (#421)
If the marketing url is not set, we shouldn't have a message
about sharing.
2021-04-26 07:53:54 -07:00
Carla Duarte
e9f63674ca AA-721: Course grade bar (#413) 2021-04-26 10:05:14 -04:00
Saad Yousaf
41b97ba638 fix: center content by fixing the container-fluid class (#419)
Co-authored-by: SaadYousaf <saadyousaf@A006-00314.local>
2021-04-24 02:36:47 +05:00
Dillon Dumesnil
8a7c61b64a feat: Switch to default values for 12 vs 24 hour time. (#420)
Our current version on react-intl doesn't support hourCycle
anyway and after speaking to product, we feel comfortable with
letting it default based on locale.
2021-04-22 12:04:13 -07:00
Dillon Dumesnil
34dbcb7ea6 fix: AA-738: Switch our use of FormattedTime to use hourCycle (#418)
We had a bug reported where learners were seeing a due date like
March 24, 24:59 instead of March 25, 00:59. This is a bug that only
shows up in Chrome. The hour12 flag overrides the hourCycle flag so
we are just going to swap the two. h23 means a 24 hour format ranging
from 0-23 (there also exists a h24 option which goes from 1-24).

See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
for any additional details on the options.
2021-04-21 12:15:13 -07:00
Kyle McCormick
ca8122686b fix: allow media access through unit iframe (#412)
Set the `allow` attribute of the unit iframe to allow
access to camera, MIDI, location, and encrpyted media.

Access to these features was implicitly allowed in older
browser versions. However, in the current versions of
at least Chromium and Firefox, iframed content must be
explicitly granted the ability to request media access.

This fixes a bug where content requiring microphone
access did not work in the Learning MFE.

TNL-7675
2021-04-21 14:30:02 -04:00
edX Transifex Bot
4472541008 fix(i18n): update translations 2021-04-16 10:18:04 -04:00
Diane Kaplan
e5c8dad319 [REV-2127] feat: update gated content lock screen to Value Prop designs (#394) 2021-04-16 08:54:03 -04:00
stvn
3e82152ae7 Merge PR #233 renovate/codecov-3.x
* Commits:
  chore(deps): update dependency codecov to v3.8.1
2021-04-14 08:21:38 -07:00
Renovate Bot
4d2bd81bf0 chore(deps): update dependency codecov to v3.8.1 2021-04-13 15:06:26 +00:00
Carla Duarte
aca45fb26e AA-720: Progress Tab Course Completion chart (#407) 2021-04-13 10:08:13 -04:00
edX Transifex Bot
d13bb04648 fix(i18n): update translations 2021-04-11 17:06:50 -04:00
Bianca Severino
8dc7593780 fix: pass username into proctoring info panel (#406)
Pass a username into the proctoring info panel, allowing staff
to view a specific learner's onboarding status while masquerading.
2021-04-09 12:55:48 -04:00
96 changed files with 4125 additions and 493 deletions

177
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -1,3 +1,5 @@
import './courseHomeMetadata.factory';
import './datesTabData.factory';
import './outlineTabData.factory';
import './progressTabData.factory';
import './upgradeCardData.factory';

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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",

View File

@@ -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,
};

View 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);
});
});

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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"
/>
)}
>

View File

@@ -96,7 +96,6 @@ function SequenceLink({
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={due}
{...timezoneFormatArgs}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -37,7 +37,6 @@ function CourseEndAlert({ payload }) {
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={endDate}
{...timezoneFormatArgs}

View File

@@ -42,7 +42,6 @@ function CourseStartAlert({ payload }) {
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={startDate}
{...timezoneFormatArgs}

View File

@@ -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',

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View 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');
});
});

View File

@@ -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}>

View File

@@ -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>

View 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('Youre 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('Youre 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();
});
});
});

View File

@@ -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);

View 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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View 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;

View File

@@ -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);

View File

@@ -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' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
</span>
)}
</span>
</OnMobile>
<OnAtLeastTablet>
<span className="h4 m-0 align-bottom">
{footerText}
{hasLetterGrades && (
<span style={{ whiteSpace: 'nowrap' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} />
</span>
)}
</span>
</OnAtLeastTablet>
</div>
</div>
);
}
CourseGradeFooter.propTypes = {
intl: intlShape.isRequired,
passingGrade: PropTypes.number.isRequired,
};
export default injectIntl(CourseGradeFooter);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}."

View File

@@ -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 = {

View File

@@ -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>
);

View File

@@ -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: 'Youre 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: 'Youre 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',

View File

@@ -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,
});

View File

@@ -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}
/>

View File

@@ -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: {

View File

@@ -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/`}

View 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);

View 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;
}

View 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);

View 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;
}

View 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);
});
});

View 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);

View 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;
}

View 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);
});
});

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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: {

View File

@@ -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(

View File

@@ -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"

View File

@@ -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>
&nbsp;
<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 = {

View 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%;
}
}

View File

@@ -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: {

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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 = () => {

View File

@@ -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();
});
});

View File

@@ -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';

View File

@@ -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>
</>
);

View File

@@ -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);

View 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);

View File

@@ -1,7 +1,9 @@
import FormattedPricing from './FormattedPricing';
import UpgradeButton from './UpgradeButton';
import UpgradeNowButton from './UpgradeNowButton';
export {
FormattedPricing,
UpgradeButton,
UpgradeNowButton,
};

View File

@@ -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">

View File

@@ -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": "Youre currently passing this course",
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
"progress.courseGrade.footer.passing": "Youre 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": "عرف كهذا المستخدم",

View File

@@ -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": "Youre currently passing this course",
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
"progress.courseGrade.footer.passing": "Youre 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, :",
"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",

View File

@@ -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": "Youre currently passing this course",
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
"progress.courseGrade.footer.passing": "Youre 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": "Its 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",

View File

@@ -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": "Youre currently passing this course",
"progress.courseGrade.footer.nonPassing": "A weighted grade of {passingGrade}% is required to pass in this course",
"progress.courseGrade.footer.passing": "Youre 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": "Its 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",

View File

@@ -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 */

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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
View 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 };

View File

@@ -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>
&nbsp;{intl.formatMessage(messages.streakDiscountMessage)}&nbsp;
<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);

View File

@@ -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 {

View File

@@ -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, youre on a roll!');
expect(screen.getByText('3 day streak')).toBeInTheDocument();
expect(screen.getByText('Keep it up, youre 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('Youve 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();
});
});

View File

@@ -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: 'Youve 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;

View File

@@ -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"